Repository: replicatedhq/kots
Pull request #1256 (2020-11-17 16:57:40)

Deploy dex


Patch:
diff --git a/cmd/kots/cli/admin-console-push-images.go b/cmd/kots/cli/admin-console-push-images.go
index 4f4530ac7..a73136c88 100644
--- a/cmd/kots/cli/admin-console-push-images.go
+++ b/cmd/kots/cli/admin-console-push-images.go
@@ -1,8 +1,16 @@
 package cli
 
 import (
+	"encoding/base64"
+	"fmt"
+	"net/url"
 	"os"
+	"strings"
 
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/aws/credentials"
+	"github.com/aws/aws-sdk-go/aws/session"
+	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/pkg/errors"
 	"github.com/replicatedhq/kots/pkg/docker/registry"
 	"github.com/replicatedhq/kots/pkg/kotsadm"
@@ -30,11 +38,22 @@ func AdminPushImagesCmd() *cobra.Command {
 			}
 
 			airgapArchive := args[0]
+			endpoint := args[1]
+			username := v.GetString("registry-username")
+			password := v.GetString("registry-password")
+			if isECR(endpoint) && username != "AWS" {
+				var err error
+				username, password, err = getECRLogin(endpoint, username, password)
+				if err != nil {
+					return errors.Wrap(err, "failed get ecr login")
+				}
+			}
+
 			options := kotsadmtypes.PushImagesOptions{
 				Registry: registry.RegistryOptions{
 					Endpoint: args[1],
-					Username: v.GetString("registry-username"),
-					Password: v.GetString("registry-password"),
+					Username: username,
+					Password: password,
 				},
 				ProgressWriter: os.Stdout,
 			}
@@ -53,3 +72,73 @@ func AdminPushImagesCmd() *cobra.Command {
 
 	return cmd
 }
+
+func isECR(endpoint string) bool {
+	if !strings.HasPrefix(endpoint, "http") {
+		// url.Parse doesn't work without scheme
+		endpoint = fmt.Sprintf("https://%s", endpoint)
+	}
+
+	parsed, err := url.Parse(endpoint)
+	if err != nil {
+		return false
+	}
+
+	return strings.HasSuffix(parsed.Hostname(), ".amazonaws.com")
+}
+
+func getECRLogin(endpoint string, keyID string, accessKey string) (string, string, error) {
+	registry, zone, err := parseECREndpoint(endpoint)
+	if err != nil {
+		return "", "", errors.Wrap(err, "failed to parse ECR endpoint")
+	}
+
+	ecrService := getECRService(keyID, accessKey, zone)
+
+	ecrToken, err := ecrService.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{
+		RegistryIds: []*string{
+			&registry,
+		},
+	})
+	if err != nil {
+		return "", "", errors.Wrap(err, "failed to get ecr token")
+	}
+
+	if len(ecrToken.AuthorizationData) == 0 {
+		return "", "", errors.Errorf("repo %s not accessible with specified credentials", endpoint)
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(*ecrToken.AuthorizationData[0].AuthorizationToken)
+	if err != nil {
+		return "", "", errors.Wrap(err, "failed to decode ecr token")
+	}
+
+	parts := strings.Split(string(decoded), ":")
+	if len(parts) != 2 {
+		return "", "", errors.New("ecr token is not in user:password format")
+	}
+
+	username := parts[0]
+	password := parts[1]
+
+	return username, password, nil
+}
+
+func getECRService(accessKeyID, secretAccessKey, zone string) *ecr.ECR {
+	awsConfig := &aws.Config{Region: aws.String(zone)}
+	awsConfig.Credentials = credentials.NewStaticCredentials(accessKeyID, secretAccessKey, "")
+	return ecr.New(session.New(awsConfig))
+}
+
+func parseECREndpoint(endpoint string) (registry, zone string, err error) {
+	splitEndpoint := strings.Split(endpoint, ".")
+	if len(splitEndpoint) < 6 {
+		return "", "", errors.Errorf("invalid ecr endpoint: %s", endpoint)
+	}
+
+	if splitEndpoint[1] != "dkr" || splitEndpoint[2] != "ecr" {
+		return "", "", errors.Errorf("only dkr and ecr endpoints are supported: %s", endpoint)
+	}
+
+	return splitEndpoint[0], splitEndpoint[3], nil
+}
diff --git a/cmd/kots/cli/identity.go b/cmd/kots/cli/identity.go
new file mode 100644
index 000000000..8d3b344e8
--- /dev/null
+++ b/cmd/kots/cli/identity.go
@@ -0,0 +1,227 @@
+package cli
+
+import (
+	"fmt"
+	"io/ioutil"
+
+	ghodssyaml "github.com/ghodss/yaml"
+	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/pkg/identity"
+	identitytypes "github.com/replicatedhq/kots/pkg/identity/types"
+	ingress "github.com/replicatedhq/kots/pkg/ingress"
+	"github.com/replicatedhq/kots/pkg/k8sutil"
+	"github.com/replicatedhq/kots/pkg/logger"
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+)
+
+func IdentityServiceCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:    "identity-service",
+		Short:  "KOTS Identity Service",
+		Hidden: true,
+	}
+
+	cmd.AddCommand(IdentityServiceInstallCmd())
+	cmd.AddCommand(IdentityServiceUninstallCmd())
+	cmd.AddCommand(IdentityServiceEnableSharedPasswordCmd())
+	cmd.AddCommand(IdentityServiceOIDCCallbackURLCmd())
+
+	return cmd
+}
+
+func IdentityServiceInstallCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:           "install",
+		Short:         "Install the KOTS Identity Service",
+		SilenceUsage:  true,
+		SilenceErrors: false,
+		PreRun: func(cmd *cobra.Command, args []string) {
+			viper.BindPFlags(cmd.Flags())
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			v := viper.GetViper()
+
+			log := logger.NewLogger()
+
+			clientset, err := k8sutil.GetClientset(kubernetesConfigFlags)
+			if err != nil {
+				return errors.Wrap(err, "failed to get clientset")
+			}
+
+			namespace := v.GetString("namespace")
+			if err := validateNamespace(namespace); err != nil {
+				return err
+			}
+
+			ingressConfig, err := ingress.GetConfig(cmd.Context(), namespace)
+			if err != nil {
+				return errors.Wrap(err, "failed to get ingress config")
+			}
+			if !ingressConfig.Enabled {
+				return errors.New("ingress is not enabled")
+			}
+
+			identityConfig := identitytypes.Config{}
+			if identityConfigPath := v.GetString("identity-config"); identityConfigPath != "" {
+				content, err := ioutil.ReadFile(identityConfigPath)
+				if err != nil {
+					return errors.Wrap(err, "failed to read identity service config file")
+				}
+				if err := ghodssyaml.Unmarshal(content, &identityConfig); err != nil {
+					return errors.Wrap(err, "failed to unmarshal identity service config yaml")
+				}
+			}
+
+			log.ChildActionWithSpinner("Deploying the Identity Service")
+
+			identityConfig.Enabled = true
+			identityConfig.DisablePasswordAuth = true
+
+			if err := identity.SetConfig(cmd.Context(), namespace, identityConfig); err != nil {
+				return errors.Wrap(err, "failed to set identity config")
+			}
+
+			if err := identity.Deploy(cmd.Context(), log, clientset, namespace, identityConfig, *ingressConfig); err != nil {
+				return errors.Wrap(err, "failed to deploy the identity service")
+			}
+
+			log.FinishSpinner()
+
+			return nil
+		},
+	}
+
+	cmd.Flags().String("identity-config", "", "path to a yaml file containing the KOTS identity service configuration")
+
+	return cmd
+}
+
+func IdentityServiceUninstallCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:           "uninstall",
+		Short:         "Uninstall the KOTS identity service",
+		Long:          "Uninstall the KOTS identity service. This will re-enable shared password authentication.",
+		SilenceUsage:  true,
+		SilenceErrors: false,
+		PreRun: func(cmd *cobra.Command, args []string) {
+			viper.BindPFlags(cmd.Flags())
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			v := viper.GetViper()
+
+			log := logger.NewLogger()
+
+			clientset, err := k8sutil.GetClientset(kubernetesConfigFlags)
+			if err != nil {
+				return errors.Wrap(err, "failed to get clientset")
+			}
+
+			namespace := v.GetString("namespace")
+			if err := validateNamespace(namespace); err != nil {
+				return err
+			}
+
+			log.ChildActionWithSpinner("Updating the Identity Service configuration")
+
+			identityConfig, err := identity.GetConfig(cmd.Context(), namespace)
+			if err != nil {
+				return errors.Wrap(err, "failed to get identity config")
+			}
+
+			identityConfig.Enabled = false
+			identityConfig.DisablePasswordAuth = false
+
+			if err := identity.SetConfig(cmd.Context(), namespace, *identityConfig); err != nil {
+				return errors.Wrap(err, "failed to set identity config")
+			}
+
+			log.FinishSpinner()
+
+			log.ChildActionWithSpinner("Uninstalling the Identity Service")
+
+			if err := identity.Undeploy(cmd.Context(), log, clientset, namespace); err != nil {
+				return errors.Wrap(err, "failed to uninstall the identity service")
+			}
+
+			log.FinishSpinner()
+
+			return nil
+		},
+	}
+
+	return cmd
+}
+
+func IdentityServiceEnableSharedPasswordCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:           "enable-shared-password",
+		Short:         "Enable shared password",
+		Long:          "Enable the shared password login option in the KOTS Admin Console.",
+		SilenceUsage:  true,
+		SilenceErrors: false,
+		PreRun: func(cmd *cobra.Command, args []string) {
+			viper.BindPFlags(cmd.Flags())
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			v := viper.GetViper()
+
+			log := logger.NewLogger()
+
+			namespace := v.GetString("namespace")
+			if err := validateNamespace(namespace); err != nil {
+				return err
+			}
+
+			log.ChildActionWithSpinner("Updating the Identity Service configuration")
+
+			identityConfig, err := identity.GetConfig(cmd.Context(), namespace)
+			if err != nil {
+				return errors.Wrap(err, "failed to get identity config")
+			}
+
+			identityConfig.DisablePasswordAuth = false
+
+			if err := identity.SetConfig(cmd.Context(), namespace, *identityConfig); err != nil {
+				return errors.Wrap(err, "failed to set identity config")
+			}
+
+			log.FinishSpinner()
+
+			return nil
+		},
+	}
+
+	return cmd
+}
+
+func IdentityServiceOIDCCallbackURLCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:           "oidc-callback-url",
+		Short:         "Prints OICD callback URL",
+		SilenceUsage:  true,
+		SilenceErrors: false,
+		PreRun: func(cmd *cobra.Command, args []string) {
+			viper.BindPFlags(cmd.Flags())
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			v := viper.GetViper()
+
+			namespace := v.GetString("namespace")
+			if err := validateNamespace(namespace); err != nil {
+				return err
+			}
+
+			identityConfig, err := identity.GetConfig(cmd.Context(), namespace)
+			if err != nil {
+				return errors.Wrap(err, "failed to get identity config")
+			}
+
+			fmt.Fprintln(cmd.OutOrStdout(), identity.DexCallbackURL(identityConfig.IngressConfig))
+
+			return nil
+		},
+	}
+
+	return cmd
+}
diff --git a/cmd/kots/cli/ingress.go b/cmd/kots/cli/ingress.go
new file mode 100644
index 000000000..5d8d7d26d
--- /dev/null
+++ b/cmd/kots/cli/ingress.go
@@ -0,0 +1,168 @@
+package cli
+
+import (
+	"io/ioutil"
+
+	ghodssyaml "github.com/ghodss/yaml"
+	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/pkg/identity"
+	"github.com/replicatedhq/kots/pkg/ingress"
+	ingresstypes "github.com/replicatedhq/kots/pkg/ingress/types"
+	"github.com/replicatedhq/kots/pkg/k8sutil"
+	"github.com/replicatedhq/kots/pkg/kotsadm"
+	"github.com/replicatedhq/kots/pkg/logger"
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+)
+
+func IngressCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:    "ingress",
+		Short:  "KOTS ingress",
+		Hidden: true,
+	}
+
+	cmd.AddCommand(IngressInstallCmd())
+	cmd.AddCommand(IngressUninstallCmd())
+
+	return cmd
+}
+
+func IngressInstallCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:           "install",
+		Short:         "Install Ingress for the KOTS Admin Console",
+		SilenceUsage:  true,
+		SilenceErrors: false,
+		PreRun: func(cmd *cobra.Command, args []string) {
+			viper.BindPFlags(cmd.Flags())
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			v := viper.GetViper()
+
+			log := logger.NewLogger()
+
+			clientset, err := k8sutil.GetClientset(kubernetesConfigFlags)
+			if err != nil {
+				return errors.Wrap(err, "failed to get clientset")
+			}
+
+			namespace := v.GetString("namespace")
+			if err := validateNamespace(namespace); err != nil {
+				return err
+			}
+
+			ingressConfig := ingresstypes.Config{}
+			if ingressConfigPath := v.GetString("ingress-config"); ingressConfigPath != "" {
+				content, err := ioutil.ReadFile(ingressConfigPath)
+				if err != nil {
+					return errors.Wrap(err, "failed to read ingress config file")
+				}
+				if err := ghodssyaml.Unmarshal(content, &ingressConfig); err != nil {
+					return errors.Wrap(err, "failed to unmarshal ingress config yaml")
+				}
+			}
+
+			log.ChildActionWithSpinner("Enabling ingress for the Admin Console")
+
+			ingressConfig.Enabled = true
+
+			if err := ingress.SetConfig(cmd.Context(), namespace, ingressConfig); err != nil {
+				return errors.Wrap(err, "failed to set identity config")
+			}
+
+			if err := kotsadm.EnsureIngress(cmd.Context(), namespace, clientset, ingressConfig); err != nil {
+				return errors.Wrap(err, "failed to ensure ingress")
+			}
+
+			log.FinishSpinner()
+
+			identityConfig, err := identity.GetConfig(cmd.Context(), namespace)
+			if err != nil {
+				return errors.Wrap(err, "failed to get identity config")
+			}
+
+			if identityConfig.Enabled {
+				log.ChildActionWithSpinner("Deploying the Identity Service")
+
+				// we have to re-deploy the identity service if kotsadm ingress is changing
+				if err := identity.Deploy(cmd.Context(), log, clientset, namespace, *identityConfig, ingressConfig); err != nil {
+					return errors.Wrap(err, "failed to deploy identity service")
+				}
+
+				log.FinishSpinner()
+			}
+
+			return nil
+		},
+	}
+
+	cmd.Flags().String("ingress-config", "", "path to a yaml file containing the KOTS ingress configuration")
+
+	return cmd
+}
+
+func IngressUninstallCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:           "uninstall",
+		Short:         "Uninstall Ingress for the KOTS Admin Console",
+		SilenceUsage:  true,
+		SilenceErrors: false,
+		PreRun: func(cmd *cobra.Command, args []string) {
+			viper.BindPFlags(cmd.Flags())
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			v := viper.GetViper()
+
+			log := logger.NewLogger()
+
+			clientset, err := k8sutil.GetClientset(kubernetesConfigFlags)
+			if err != nil {
+				return errors.Wrap(err, "failed to get clientset")
+			}
+
+			namespace := v.GetString("namespace")
+			if err := validateNamespace(namespace); err != nil {
+				return err
+			}
+
+			identityConfig, err := identity.GetConfig(cmd.Context(), namespace)
+			if err != nil {
+				return errors.Wrap(err, "failed to get identity config")
+			}
+
+			if identityConfig.Enabled {
+				return errors.New("identity service is enabled")
+			}
+
+			log.ChildActionWithSpinner("Updating the Admin Console ingress configuration")
+
+			ingressConfig, err := ingress.GetConfig(cmd.Context(), namespace)
+			if err != nil {
+				return errors.Wrap(err, "failed to get ingress config")
+			}
+
+			ingressConfig.Enabled = false
+
+			if err := ingress.SetConfig(cmd.Context(), namespace, *ingressConfig); err != nil {
+				return errors.Wrap(err, "failed to set ingress config")
+			}
+
+			log.FinishSpinner()
+
+			log.ChildActionWithSpinner("Uninstalling ingress for the Admin Console")
+
+			if err := kotsadm.DeleteIngress(cmd.Context(), namespace, clientset); err != nil {
+				return errors.Wrap(err, "failed to uninstall ingress")
+			}
+
+			log.FinishSpinner()
+
+			return nil
+		},
+	}
+
+	cmd.Flags().String("ingress-config", "", "path to a yaml file containing the KOTS ingress configuration")
+
+	return cmd
+}
diff --git a/cmd/kots/cli/install.go b/cmd/kots/cli/install.go
index 47c733e09..9d50245c0 100644
--- a/cmd/kots/cli/install.go
+++ b/cmd/kots/cli/install.go
@@ -11,14 +11,16 @@ import (
 	"os"
 	"os/signal"
 	"path/filepath"
-	"strings"
 	"time"
 
 	cursor "github.com/ahmetalpbalkan/go-cursor"
+	ghodssyaml "github.com/ghodss/yaml"
 	"github.com/manifoldco/promptui"
 	"github.com/pkg/errors"
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	"github.com/replicatedhq/kots/pkg/auth"
+	identitytypes "github.com/replicatedhq/kots/pkg/identity/types"
+	ingresstypes "github.com/replicatedhq/kots/pkg/ingress/types"
 	"github.com/replicatedhq/kots/pkg/k8sutil"
 	"github.com/replicatedhq/kots/pkg/kotsadm"
 	"github.com/replicatedhq/kots/pkg/kotsadm/types"
@@ -29,7 +31,6 @@ import (
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/util/validation"
 	"k8s.io/client-go/kubernetes"
 )
 
@@ -66,16 +67,7 @@ func InstallCmd() *cobra.Command {
 
 			namespace := v.GetString("namespace")
 
-			if namespace != "" {
-				if strings.Contains(namespace, "_") {
-					return errors.New("a namespace should not contain the _ character")
-				}
-
-				errs := validation.IsValidLabelValue(namespace)
-				if len(errs) > 0 {
-					return errors.New(errs[0])
-				}
-			} else {
+			if namespace == "" {
 				enteredNamespace, err := promptForNamespace(upstream)
 				if err != nil {
 					return errors.Wrap(err, "failed to prompt for namespace")
@@ -83,6 +75,9 @@ func InstallCmd() *cobra.Command {
 
 				namespace = enteredNamespace
 			}
+			if err := validateNamespace(namespace); err != nil {
+				return err
+			}
 
 			var applicationMetadata []byte
 			if airgapBundle := v.GetString("airgap-bundle"); airgapBundle != "" {
@@ -156,6 +151,20 @@ func InstallCmd() *cobra.Command {
 				}
 			}
 
+			ingressConfig, err := getIngressConfig(v)
+			if err != nil {
+				return errors.Wrap(err, "failed to get ingress config")
+			}
+
+			identityConfig, err := getIdentityConfig(v)
+			if err != nil {
+				return errors.Wrap(err, "failed to get identity config")
+			}
+
+			if identityConfig.Enabled && !ingressConfig.Enabled {
+				return errors.New("KOTS identity service requires ingress to be enabled")
+			}
+
 			deployOptions := kotsadmtypes.DeployOptions{
 				Namespace:                 namespace,
 				KubernetesConfigFlags:     kubernetesConfigFlags,
@@ -176,6 +185,7 @@ func InstallCmd() *cobra.Command {
 				HTTPProxyEnvValue:         v.GetString("http-proxy"),
 				HTTPSProxyEnvValue:        v.GetString("https-proxy"),
 				NoProxyEnvValue:           v.GetString("no-proxy"),
+
 				KotsadmOptions: kotsadmtypes.KotsadmOptions{
 					OverrideVersion:   v.GetString("kotsadm-tag"),
 					OverrideRegistry:  registryEndpoint,
@@ -183,6 +193,9 @@ func InstallCmd() *cobra.Command {
 					Username:          registryUsername,
 					Password:          registryPassword,
 				},
+
+				IdentityConfig: *identityConfig,
+				IngressConfig:  *ingressConfig,
 			}
 
 			timeout, err := time.ParseDuration(v.GetString("wait-duration"))
@@ -375,7 +388,7 @@ func InstallCmd() *cobra.Command {
 	// the following group of flags are experiemental and can be used to pull and push images during install time
 	cmd.Flags().Bool("rewrite-images", false, "set to true to force all container images to be rewritten and pushed to a local registry")
 	cmd.Flags().String("image-namespace", "", "the namespace/org in the docker registry to push images to (required when --rewrite-images is set)")
-	// set this to http://127.0.0.1:30065/api/v1 in dev environment
+	// set this to http://127.0.0.1:30000/api/v1 in dev environment
 	cmd.Flags().String("registry-endpoint", "", "the endpoint of the local docker registry to use when pushing images (required when --rewrite-images is set)")
 	cmd.Flags().MarkHidden("rewrite-images")
 	cmd.Flags().MarkHidden("image-namespace")
@@ -391,6 +404,16 @@ func InstallCmd() *cobra.Command {
 	cmd.Flags().Bool("with-dockerdistribution", false, "when set, kots install will deploy a local instance of docker distribution for storage")
 	cmd.Flags().Bool("storage-base-uri-plainhttp", false, "when set, use plain http (not https) connecting to the local oci storage")
 
+	cmd.Flags().Bool("enable-identity-service", false, "when set, the KOTS identity service will be enabled")
+	cmd.Flags().MarkHidden("enable-identity-service")
+	cmd.Flags().String("identity-config", "", "path to a yaml file containing the KOTS identity service configuration")
+	cmd.Flags().MarkHidden("identity-config")
+
+	cmd.Flags().Bool("enable-ingress", false, "when set, ingress will be enabled for the KOTS Admin Console")
+	cmd.Flags().MarkHidden("enable-ingress")
+	cmd.Flags().String("ingress-config", "", "path to a yaml file containing the KOTS Admin Console ingress configuration")
+	cmd.Flags().MarkHidden("ingress-config")
+
 	return cmd
 }
 
@@ -411,22 +434,7 @@ func promptForNamespace(upstreamURI string) (string, error) {
 		Label:     "Enter the namespace to deploy to:",
 		Templates: templates,
 		Default:   u.Hostname(),
-		Validate: func(input string) error {
-			if len(input) == 0 {
-				return errors.New("invalid namespace")
-			}
-
-			if strings.Contains(input, "_") {
-				return errors.New("a namespace should not contain the _ character")
-			}
-
-			errs := validation.IsValidLabelValue(input)
-			if len(errs) > 0 {
-				return errors.New(errs[0])
-			}
-
-			return nil
-		},
+		Validate:  validateNamespace,
 	}
 
 	for {
@@ -498,3 +506,51 @@ func uploadAirgapArchive(deployOptions kotsadmtypes.DeployOptions, clientset *ku
 
 	return false, nil
 }
+
+func getIngressConfig(v *viper.Viper) (*ingresstypes.Config, error) {
+	ingressConfigPath := v.GetString("ingress-config")
+	enableIngress := v.GetBool("enable-ingress") || ingressConfigPath != ""
+
+	ingressConfig := ingresstypes.Config{}
+
+	if !enableIngress {
+		return &ingressConfig, nil
+	}
+
+	ingressConfig.Enabled = true
+
+	if ingressConfigPath != "" {
+		content, err := ioutil.ReadFile(ingressConfigPath)
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to read ingress config file")
+		}
+		if err := ghodssyaml.Unmarshal(content, &ingressConfig); err != nil {
+			return nil, errors.Wrap(err, "failed to unmarshal ingress config yaml")
+		}
+	}
+	return &ingressConfig, nil
+}
+
+func getIdentityConfig(v *viper.Viper) (*identitytypes.Config, error) {
+	identityConfigPath := v.GetString("identity-config")
+	enableIdentityService := v.GetBool("enable-identity-service") || identityConfigPath != ""
+
+	identityConfig := identitytypes.Config{}
+
+	if !enableIdentityService {
+		return &identityConfig, nil
+	}
+
+	identityConfig.Enabled = true
+
+	if identityConfigPath != "" {
+		content, err := ioutil.ReadFile(identityConfigPath)
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to read identity service config file")
+		}
+		if err := ghodssyaml.Unmarshal(content, &identityConfig); err != nil {
+			return nil, errors.Wrap(err, "failed to unmarshal identity service config yaml")
+		}
+	}
+	return &identityConfig, nil
+}
diff --git a/cmd/kots/cli/namespace.go b/cmd/kots/cli/namespace.go
new file mode 100644
index 000000000..65f48acbe
--- /dev/null
+++ b/cmd/kots/cli/namespace.go
@@ -0,0 +1,24 @@
+package cli
+
+import (
+	"strings"
+
+	"github.com/pkg/errors"
+	"k8s.io/apimachinery/pkg/util/validation"
+)
+
+func validateNamespace(namespace string) error {
+	if namespace == "" {
+		return errors.New("namespace is required")
+	}
+	if strings.Contains(namespace, "_") {
+		return errors.New("a namespace should not contain the _ character")
+	}
+
+	errs := validation.IsValidLabelValue(namespace)
+	if len(errs) > 0 {
+		return errors.New(errs[0])
+	}
+
+	return nil
+}
diff --git a/cmd/kots/cli/remove.go b/cmd/kots/cli/remove.go
index 365b21d85..00d1c5414 100644
--- a/cmd/kots/cli/remove.go
+++ b/cmd/kots/cli/remove.go
@@ -23,7 +23,7 @@ func RemoveCmd() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:           "remove [slug]",
 		Short:         "Remove an application from console",
-		Long:          `Remove application identified by slug from Admin Console.`,
+		Long:          `Remove application reference identified by slug from Admin Console.  This command does not remove application resources from the cluster.`,
 		SilenceUsage:  true,
 		SilenceErrors: false,
 		PreRun: func(cmd *cobra.Command, args []string) {
@@ -57,7 +57,7 @@ func RemoveCmd() *cobra.Command {
 			stopCh := make(chan struct{})
 			defer close(stopCh)
 
-			log.ActionWithoutSpinner("Removing application %s", appSlug)
+			log.ActionWithoutSpinner("Removing application %s reference from Admin Console", appSlug)
 
 			localPort, errChan, err := k8sutil.PortForward(kubernetesConfigFlags, 0, 3000, namespace, podName, false, stopCh, log)
 			if err != nil {
@@ -121,7 +121,7 @@ func RemoveCmd() *cobra.Command {
 					if v.GetBool("force") {
 						return errors.Wrap(errors.New(response.Error), "failed to remove app")
 					} else {
-						return errors.Wrap(errors.New(response.Error), "failed to remove app, command may be retried with --force flag")
+						return errors.Errorf("Application is already deployed. Re-run the command with --force flag to remove application reference anyway.")
 					}
 				} else {
 					return errors.Wrapf(errors.New(response.Error), "unexpected status code from %v", resp.StatusCode)
@@ -135,7 +135,7 @@ func RemoveCmd() *cobra.Command {
 	}
 
 	cmd.Flags().StringP("namespace", "n", "", "the namespace in which kots/kotsadm is installed")
-	cmd.Flags().BoolP("force", "f", false, "try removing application ignoring errors if possible")
+	cmd.Flags().BoolP("force", "f", false, "removing application reference even if it was already deployed")
 
 	return cmd
 }
diff --git a/cmd/kots/cli/root.go b/cmd/kots/cli/root.go
index 0aff13a55..8ff657fd1 100644
--- a/cmd/kots/cli/root.go
+++ b/cmd/kots/cli/root.go
@@ -35,6 +35,8 @@ func RootCmd() *cobra.Command {
 	cmd.AddCommand(VersionCmd())
 	cmd.AddCommand(BackupCmd())
 	cmd.AddCommand(RestoreCmd())
+	cmd.AddCommand(IngressCmd())
+	cmd.AddCommand(IdentityServiceCmd())
 
 	viper.BindPFlags(cmd.Flags())
 
diff --git a/go.mod b/go.mod
index f9b841e59..c72d3bcef 100644
--- a/go.mod
+++ b/go.mod
@@ -17,6 +17,7 @@ require (
 	github.com/bugsnag/panicwrap v1.2.0 // indirect
 	github.com/containerd/continuity v0.0.0-20200228182428-0f16d7a0959c // indirect
 	github.com/containers/image/v5 v5.5.2
+	github.com/dexidp/dex v0.0.0-20201105145354-71bbbee07527
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/docker/distribution v2.7.1+incompatible
 	github.com/docker/go-units v0.4.0
@@ -29,7 +30,7 @@ require (
 	github.com/golang/snappy v0.0.1 // indirect
 	github.com/google/gofuzz v1.1.0
 	github.com/google/uuid v1.1.1
-	github.com/gorilla/handlers v1.4.2 // indirect
+	github.com/gorilla/handlers v1.5.1 // indirect
 	github.com/gotestyourself/gotestyourself v2.2.0+incompatible // indirect
 	github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect
 	github.com/kr/text v0.2.0 // indirect
@@ -44,8 +45,10 @@ require (
 	github.com/pierrec/lz4 v2.2.6+incompatible // indirect
 	github.com/pkg/errors v0.9.1
 	github.com/replicatedhq/kurl/kurlkinds v0.0.0-20200617224429-55f493a72baf
-	github.com/replicatedhq/troubleshoot v0.9.51
+	github.com/replicatedhq/troubleshoot v0.9.52
 	github.com/replicatedhq/yaml/v3 v3.0.0-beta5-replicatedhq
+	github.com/russellhaering/goxmldsig v1.1.0 // indirect
+	github.com/segmentio/ksuid v1.0.3
 	github.com/sergi/go-diff v1.1.0
 	github.com/spf13/cobra v1.0.0
 	github.com/spf13/viper v1.6.2
diff --git a/go.sum b/go.sum
index c33e06abe..1e4ef92f3 100644
--- a/go.sum
+++ b/go.sum
@@ -79,6 +79,7 @@ github.com/Masterminds/sprig/v3 v3.1.0/go.mod h1:ONGMf7UfYGAbMXCZmQLy8x3lCDIPrEZ
 github.com/Masterminds/squirrel v1.2.0 h1:K1NhbTO21BWG47IVR0OnIZuE0LZcXAYqywrC3Ko53KI=
 github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
 github.com/Masterminds/vcs v1.13.1/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA=
+github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
 github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
 github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5 h1:ygIc8M6trr62pF5DucadTWGdEB4mEyvzi0e2nbcmcyA=
 github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
@@ -140,6 +141,8 @@ github.com/bazelbuild/bazel-gazelle v0.19.1-0.20191105222053-70208cbdc798/go.mod
 github.com/bazelbuild/buildtools v0.0.0-20190731111112-f720930ceb60/go.mod h1:5JP0TXzWDHXv8qvxRC4InIazwdyDseBDbzESUMKk1yU=
 github.com/bazelbuild/buildtools v0.0.0-20190917191645-69366ca98f89/go.mod h1:5JP0TXzWDHXv8qvxRC4InIazwdyDseBDbzESUMKk1yU=
 github.com/bazelbuild/rules_go v0.0.0-20190719190356-6dae44dc5cab/go.mod h1:MC23Dc/wkXEyk3Wpq6lCqz0ZAYOZDw2DR5y3N1q2i7M=
+github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
+github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
 github.com/beevik/ntp v0.2.0 h1:sGsd+kAXzT0bfVfzJfce04g+dSRfrs+tbQW8lweuYgw=
 github.com/beevik/ntp v0.2.0/go.mod h1:hIHWr+l3+/clUnF44zdK+CWW7fO8dR5cIylAQ76NRpg=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -170,6 +173,8 @@ github.com/bugsnag/panicwrap v1.2.0 h1:OzrKrRvXis8qEvOkfcxNcYbOd2O7xXS2nnKMEMABF
 github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
 github.com/caddyserver/caddy v1.0.3/go.mod h1:G+ouvOY32gENkJC+jhgl62TyhvqEsFaDiZ4uw0RzP1E=
 github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
+github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
+github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/prettybench v0.0.0-20150116022406-03b8cfe5406c/go.mod h1:Xe6ZsFhtM8HrDku0pxJ3/Lr51rwykrzgFwpmTzleatY=
 github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
@@ -192,6 +197,7 @@ github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJ
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/clusterhq/flocker-go v0.0.0-20160920122132-2b8b7259d313/go.mod h1:P1wt9Z3DP8O6W3rvwCt0REIlshg1InHImaLW0t3ObY0=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa h1:OaNxuTZr7kxeODyLWsRMC+OD03aFUH+mW6r2d+MWa5Y=
 github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
 github.com/codegangsta/negroni v1.0.0/go.mod h1:v0y3T5G7Y1UlFfyxFn/QLRU4a2EuNau2iZY63YTKWo0=
 github.com/container-storage-interface/spec v1.1.0/go.mod h1:6URME8mwIBbpVyZV93Ce5St17xBiQJQY67NDsuohiy4=
@@ -225,17 +231,27 @@ github.com/containers/storage v1.20.2 h1:tw/uKRPDnmVrluIzer3dawTFG/bTJLP8IEUyHFh
 github.com/containers/storage v1.20.2/go.mod h1:oOB9Ie8OVPojvoaKWEGSEtHbXUAs+tSyr7RO7ZGteMc=
 github.com/coredns/corefile-migration v1.0.6/go.mod h1:OFwBp/Wc9dJt5cAZzHWMNhK1r5L0p0jDwIBc6j8NC8E=
 github.com/coredns/corefile-migration v1.0.7/go.mod h1:OFwBp/Wc9dJt5cAZzHWMNhK1r5L0p0jDwIBc6j8NC8E=
+github.com/coreos/bbolt v1.3.2 h1:wZwiHHUieZCquLkDL0B8UhzreNWsPHooDAG3q34zk0s=
 github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
+github.com/coreos/etcd v3.3.10+incompatible h1:jFneRYjIvLMLhDLCzuTuU4rSJUjRplcJQ7pD7MnhC04=
 github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
 github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
+github.com/coreos/go-oidc v2.1.0+incompatible h1:sdJrfw8akMnCuUlaZU3tE/uYXFgfqom8DBE9so9EBsM=
 github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
+github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk=
+github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
 github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
+github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
 github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
 github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e h1:Wf6HqHfScWJN9/ZjdUKyjop4mf3Qdd+1TvvltAvM3m8=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c=
+github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f h1:lBNOc5arjvs8E5mO2tbpBpLoyyu8B6e44T7hJy6potg=
 github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
@@ -251,6 +267,12 @@ github.com/daviddengcn/go-colortext v0.0.0-20160507010035-511bcaf42ccd/go.mod h1
 github.com/deislabs/oras v0.8.1 h1:If674KraJVpujYR00rzdi0QAmW4BxzMJPVAZJKuhQ0c=
 github.com/deislabs/oras v0.8.1/go.mod h1:Mx0rMSbBNaNfY9hjpccEnxkOqJL6KGjtxNHPLC4G4As=
 github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
+github.com/dexidp/dex v0.0.0-20201105145354-71bbbee07527 h1:41NjNlZec5/74BndW4BFQnMyCAOMuhpD0pPLGmgVleU=
+github.com/dexidp/dex v0.0.0-20201105145354-71bbbee07527/go.mod h1:qDmsfKCzXF8yAatFeruAXk+WmGhduJqTBdYyUU7Sx/A=
+github.com/dexidp/dex v2.13.0+incompatible h1:EQPpzCi51omkwBe0KYpRGaV3rk6CVvjcqeMGCe3Q00w=
+github.com/dexidp/dex v2.13.0+incompatible/go.mod h1:cRGkPWqKhDD1FMCICe2JbYDdVR2xGLa38F6iuH/jNAs=
+github.com/dexidp/dex/api/v2 v2.0.0 h1:bvge1sRmzVzWPWp4WlMzS04lcNQA+jFzHqKV3066bRw=
+github.com/dexidp/dex/api/v2 v2.0.0/go.mod h1:k5arBJT1QYvpsEY3sEd0NXJp3hKWKuUUfzJ3BlcqPdM=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
@@ -284,6 +306,7 @@ github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q=
 github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=
 github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
 github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
 github.com/elazarl/goproxy v0.0.0-20191011121108-aa519ddbe484 h1:pEtiCjIXx3RvGjlUJuCNxNOw0MNblyR9Wi+vJGBFh+8=
@@ -294,6 +317,7 @@ github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb
 github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
 github.com/emicklei/go-restful v2.9.6+incompatible h1:tfrHha8zJ01ywiOEC1miGY8st1/igzWB8OmvPgoYX7w=
 github.com/emicklei/go-restful v2.9.6+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/euank/go-kmsg-parser v2.0.0+incompatible/go.mod h1:MhmAMZ8V4CYH4ybgdRwPr2TU5ThnS43puaKEMpja1uw=
@@ -306,6 +330,8 @@ github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwo
 github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=
+github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
 github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
 github.com/frankban/quicktest v1.7.2 h1:2QxQoC1TS09S7fhCPsrvqYdvP1H5M1P1ih5ABm3BTYk=
@@ -389,6 +415,8 @@ github.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+
 github.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA=
 github.com/go-openapi/validate v0.19.5/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85nY1c2z52x1Gk4=
 github.com/go-ozzo/ozzo-validation v3.5.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU=
+github.com/go-redis/redis v6.15.6+incompatible h1:H9evprGPLI8+ci7fxQx6WNZHJSb7be8FqJQRhdQZ5Sg=
+github.com/go-redis/redis v6.15.6+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
 github.com/go-redis/redis/v7 v7.2.0 h1:CrCexy/jYWZjW0AyVoHlcJUeZN19VWlbepTh1Vq6dJs=
 github.com/go-redis/redis/v7 v7.2.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
@@ -430,6 +458,7 @@ github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
 github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84=
 github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
 github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls=
 github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
@@ -441,6 +470,8 @@ github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4er
 github.com/golang/groupcache v0.0.0-20180513044358-24b0969c4cb7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef h1:veQD95Isof8w9/WXiA+pa3tz3fJXkt5B7QaRBrM62gk=
 github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 h1:ZgQEtGgCBiWRM39fZuwSd1LwSqqSW0hOdXCYYDX0R3I=
+github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3 h1:I4BOK3PBMjhWfQM2zPJKK7lOBGsrsvOB7kBELP33hiE=
 github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
 github.com/golang/mock v1.0.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
@@ -540,7 +571,11 @@ github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:
 github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
 github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg=
 github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
+github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
+github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
+github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
 github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
+github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
 github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
 github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
 github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
@@ -555,11 +590,16 @@ github.com/gotestyourself/gotestyourself v2.2.0+incompatible/go.mod h1:zZKM6oeNM
 github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM=
 github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
 github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
+github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4 h1:z53tR0945TRRQO/fLEVPI6SMv7ZflF0TEaTAoU7tOzg=
 github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
+github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=
 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
 github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw=
 github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
+github.com/grpc-ecosystem/grpc-gateway v1.9.5 h1:UImYN5qQ8tuGpGE16ZmjvcTtTw24zw1QAp/SlnNrZhI=
 github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
+github.com/gtank/cryptopasta v0.0.0-20170601214702-1f550f6f2f69 h1:7xsUJsB2NrdcttQPa7JLEaGzvdbk7KvfrjgHZXOQRo0=
+github.com/gtank/cryptopasta v0.0.0-20170601214702-1f550f6f2f69/go.mod h1:YLEMZOtU+AZ7dhN9T/IpGhXVGly2bvkJQ+zxj3WeVQo=
 github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -611,6 +651,8 @@ github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhB
 github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
 github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
 github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
+github.com/jonboulle/clockwork v0.2.0 h1:J2SLSdy7HgElq8ekSl2Mxh6vrRNFxqbXGenYH2I02Vs=
+github.com/jonboulle/clockwork v0.2.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
 github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -664,7 +706,10 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/kubernetes-csi/csi-lib-utils v0.7.0/go.mod h1:bze+2G9+cmoHxN6+WyG1qT4MDxgZJMLGwc7V4acPNm0=
 github.com/kubernetes-csi/csi-test v2.0.0+incompatible/go.mod h1:YxJ4UiuPWIhMBkxUKY5c267DyA0uDZ/MtAimhx/2TA0=
 github.com/kubernetes-csi/external-snapshotter/v2 v2.2.0-rc1/go.mod h1:ArR2bKKq4dEfoRwRH69kt/xYOliLUlaqCZ+LM1teU8Q=
+github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
 github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
 github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
 github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
@@ -724,6 +769,7 @@ github.com/mattn/go-shellwords v1.0.5/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vq
 github.com/mattn/go-shellwords v1.0.10 h1:Y7Xqm8piKOO3v10Thp7Z36h4FYFjt5xB//6XvOrs2Gw=
 github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
 github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
+github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
 github.com/mattn/go-sqlite3 v1.12.0 h1:u/x3mp++qUxvYfulZ4HKOvVO0JWhk7HtE8lWhbGz/Do=
 github.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
 github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
@@ -766,6 +812,7 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
 github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/mohae/deepcopy v0.0.0-20170603005431-491d3605edfb/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
+github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 github.com/mozilla/tls-observatory v0.0.0-20180409132520-8791a200eb40/go.mod h1:SrKMQvPiws7F7iqYp8/TX+IhxCYhzr6N/1yb8cwHsGk=
 github.com/mozilla/tls-observatory v0.0.0-20190404164649-a3c1b6cfecfd/go.mod h1:SrKMQvPiws7F7iqYp8/TX+IhxCYhzr6N/1yb8cwHsGk=
@@ -876,7 +923,10 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
+github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021 h1:0XM1XL/OFFJjXsYXlG30spTkV/E9+gmd5GD1w2HE8xM=
 github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
+github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU=
+github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
 github.com/pquerna/ffjson v0.0.0-20180717144149-af8b230fcd20/go.mod h1:YARuvh7BUWHNhzDq2OM5tzR2RiCcN2D7sapiKyCel/M=
 github.com/pquerna/ffjson v0.0.0-20181028064349-e517b90714f7 h1:gGBSHPOU7g8YjTbhwn+lvFm2VDEhhA+PwDIlstkgSxE=
 github.com/pquerna/ffjson v0.0.0-20181028064349-e517b90714f7/go.mod h1:YARuvh7BUWHNhzDq2OM5tzR2RiCcN2D7sapiKyCel/M=
@@ -890,6 +940,7 @@ github.com/prometheus/client_golang v1.0.0 h1:vrDKnkGzuGvhNAL56c7DBz29ZL+KxnoR0x
 github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
 github.com/prometheus/client_golang v1.1.0 h1:BQ53HtBmfOitExawJ6LokA4x8ov/z0SYYb0+HxJfRI8=
 github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
+github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
 github.com/prometheus/client_golang v1.5.1 h1:bdHYieyGlH+6OLEk2YQha8THib30KP0/yD0YH9m6xcA=
 github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
@@ -923,11 +974,16 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T
 github.com/quasilyte/go-consistent v0.0.0-20190521200055-c6f3937de18c/go.mod h1:5STLWrekHfjyYwxBRVRXNOSewLJ3PWfDJd1VyTS21fI=
 github.com/quobyte/api v0.1.2/go.mod h1:jL7lIHrmqQ7yh05OJ+eEEdHr0u/kmT1Ff9iHd+4H6VI=
 github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M=
+github.com/replicatedhq/kots/kotsadm v0.0.0-20201117215543-aaf2412a5bce h1:IGQdZierZMF1HmnniaqBEi1nadQ9+G8h5ttomNQehzs=
+github.com/replicatedhq/kots/kotsadm v0.0.0-20201117224054-d43def3e28de h1:fvBVPvuNlcclS8NyZ7dNLZnUcriF6zf9P5jzlqYEdPc=
+github.com/replicatedhq/kots/kotsadm v0.0.0-20201117232100-1e173a7577d0 h1:LTLju4MnsAe30MV6QPDSbMybucDMx1wzI7FXcLgV7nU=
 github.com/replicatedhq/kurl/kurlkinds v0.0.0-20200617224429-55f493a72baf h1:QPJidrFEmB3ej7ugwss3iYnVM+k6uyL1AJ85xdcpDgg=
 github.com/replicatedhq/kurl/kurlkinds v0.0.0-20200617224429-55f493a72baf/go.mod h1:ZSyX/rt3a2f6r6TgCARPUlbZ1IUAxNIUrUxR9vzEUKQ=
 github.com/replicatedhq/termui/v3 v3.1.1-0.20200811145416-f40076d26851/go.mod h1:JDxG6+uubnk9/BZ2yUsyAJJwlptjrnmB2MPF5d2Xe/8=
 github.com/replicatedhq/troubleshoot v0.9.51 h1:phfXvWN8n/QhD2uhDUHpkv7tEjyoSzoNrQUe3CGH/T4=
 github.com/replicatedhq/troubleshoot v0.9.51/go.mod h1:5nWpBzGFtgu/U3TxrWEYZQiSOkjzR04HpDMAIq6dCjE=
+github.com/replicatedhq/troubleshoot v0.9.52 h1:e+3PtQY5lqDtH79vOXiLZCqX2GdbiBTETkJl6rdPEy8=
+github.com/replicatedhq/troubleshoot v0.9.52/go.mod h1:5nWpBzGFtgu/U3TxrWEYZQiSOkjzR04HpDMAIq6dCjE=
 github.com/replicatedhq/yaml/v3 v3.0.0-beta5-replicatedhq h1:PwPggruelq2336c1Ayg5STFqgbn/QB1tWLQwrVlU7ZQ=
 github.com/replicatedhq/yaml/v3 v3.0.0-beta5-replicatedhq/go.mod h1:Txa7LopbYCU8aRgmNe0n+y/EPMz50NbCPcVVJBquwag=
 github.com/robfig/cron v1.1.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
@@ -941,11 +997,15 @@ github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE
 github.com/rubenv/sql-migrate v0.0.0-20200212082348-64f95ea68aa3 h1:xkBtI5JktwbW/vf4vopBbhYsRFTGfQWHYXzC0/qYwxI=
 github.com/rubenv/sql-migrate v0.0.0-20200212082348-64f95ea68aa3/go.mod h1:rtQlpHw+eR6UrqaS3kX1VYeaCxzCVdimDS7g5Ln4pPc=
 github.com/rubiojr/go-vhd v0.0.0-20160810183302-0bfd3b39853c/go.mod h1:DM5xW0nvfNNm2uytzsvhI3OnX8uzaRAg8UX/CnDqbto=
+github.com/russellhaering/goxmldsig v0.0.0-20180430223755-7acd5e4a6ef7/go.mod h1:Oz4y6ImuOQZxynhbSXk7btjEfNBtGlj2dcaOvXl2FSM=
+github.com/russellhaering/goxmldsig v1.1.0 h1:lK/zeJie2sqG52ZAlPNn1oBBqsIsEKypUUBGpYYF6lk=
+github.com/russellhaering/goxmldsig v1.1.0/go.mod h1:QK8GhXPB3+AfuCrfo0oRISa9NfzeCpWmxeGnqEpDF9o=
 github.com/russross/blackfriday v0.0.0-20170610170232-067529f716f4/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
 github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/ryanuber/go-glob v0.0.0-20170128012129-256dc444b735/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
+github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
 github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
@@ -1058,6 +1118,8 @@ github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG
 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
 github.com/tchap/go-patricia v2.3.0+incompatible h1:GkY4dP3cEfEASBPPkWd+AmjYxhmDkqO9/zg7R0lSQRs=
 github.com/tchap/go-patricia v2.3.0+incompatible/go.mod h1:bmLyhP68RS6kStMGxByiQ23RP/odRBOTVjwp2cDyi6I=
+github.com/testcontainers/testcontainers-go v0.0.9 h1:mwvFz+FkuQMqQ9oLkG4cVzPsZTRmrCo2NcaerJNaptA=
+github.com/testcontainers/testcontainers-go v0.0.9/go.mod h1:0Qe9qqjNZgxHzzdHPWwmQ2D49FFO7920hLdJ4yUJXJI=
 github.com/thecodeteam/goscaleio v0.1.0/go.mod h1:68sdkZAsK8bvEwBlbQnlLS+xU+hvLYM/iQ8KXej1AwM=
 github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
 github.com/timakin/bodyclose v0.0.0-20190721030226-87058b9bfcec/go.mod h1:Qimiffbc6q9tBWlVV6x0P9sat/ao1xEkREYPPj9hphk=
@@ -1065,10 +1127,13 @@ github.com/timakin/bodyclose v0.0.0-20190930140734-f7f2e9bca95e/go.mod h1:Qimiff
 github.com/tj/go-spin v1.1.0 h1:lhdWZsvImxvZ3q1C5OIB7d72DuOwP4O2NdBg9PyzNds=
 github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4=
 github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
+github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5 h1:LnC5Kc/wtumK+WB441p7ynQJzVuNRJiqddSIE3IlSEQ=
 github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
 github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9 h1:vY5WqiEon0ZSTGM3ayVVi+twaHKHDFUVloaQ/wug9/c=
 github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9/go.mod h1:q+QjxYvZ+fpjMXqs+XEriussHjSYqeXVnAdSV1tkMYk=
+github.com/ugorji/go v1.1.4 h1:j4s+tAvLfL3bZyefP2SEWmhBzmuIlH/eqNuPdFPgngw=
 github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
+github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8 h1:3SVOIvH7Ae1KRYyQWRjXWJEA9sS/c/pjvH++55Gr648=
 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
 github.com/ulikunitz/xz v0.5.5/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=
 github.com/ulikunitz/xz v0.5.6 h1:jGHAfXawEGZQ3blwU5wnWKQJvAraT7Ftq9EXjnXYgt8=
@@ -1116,6 +1181,7 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17
 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
+github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8=
 github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
 github.com/xlab/handysort v0.0.0-20150421192137-fb3537ed64a1/go.mod h1:QcJo0QPSfTONNIgpN5RA8prR7fF8nkF6cTWTcNerRO8=
 github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
@@ -1132,6 +1198,7 @@ go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk=
 go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
 go.etcd.io/bbolt v1.3.4 h1:hi1bXHMVrlQh6WwxAy+qZCV/SYIlqo+Ushwdpa4tAKg=
 go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
+go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738 h1:VcrIfasaLFkyjk6KNlXQSzO+B0fZcnECiDrKJsfxka0=
 go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
 go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=
 go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=
@@ -1279,6 +1346,7 @@ golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5h
 golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181213200352-4d1cda033e06/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190122071731-054c452bb702/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190124100055-b90733256f2e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1340,6 +1408,7 @@ golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxb
 golang.org/x/tools v0.0.0-20170915040203-e531a2a1c15f/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20180810170437-e96c4e24768d/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -1399,6 +1468,8 @@ google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E
 google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
 google.golang.org/api v0.9.0 h1:jbyannxz0XFD3zdjgrSUsaJbgpH4eTrkdhRChkHPfO8=
 google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.15.0 h1:yzlyyDW/J0w8yNFJIhiAJy4kq74S+1DOLdawELNxFMA=
+google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
 google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -1426,6 +1497,8 @@ google.golang.org/genproto v0.0.0-20191220175831-5c49e3ecc1c1/go.mod h1:n3cpQtvx
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
 google.golang.org/genproto v0.0.0-20200731012542-8145dea6a485 h1:wTk5DQB3+1darAz4Ldomo0r5bUOCKX7gilxQ4sb2kno=
 google.golang.org/genproto v0.0.0-20200731012542-8145dea6a485/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/grpc v1.26.0 h1:2dTRdpdFEEhJYQD8EMLB61nnrzSCTbG38PhqdhvOltg=
+google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
 google.golang.org/grpc v1.29.1 h1:EC2SB8S04d2r73uptxphDSUG+kTKVgjRPF+N3xpxRB4=
 google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
@@ -1443,6 +1516,8 @@ gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20191105091915-95d230a53780 h1:CEBpW6C191eozfEuWdUmIAHn7lwlLxJ7HVdr2e2Tsrw=
 gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20191105091915-95d230a53780/go.mod h1:3HH7i1SgMqlzxCcBmUHW657sD4Kvv9sC3HpL3YukzwA=
+gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM=
+gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
@@ -1464,12 +1539,16 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
 gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
 gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
 gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU=
+gopkg.in/ldap.v2 v2.5.1/go.mod h1:oI0cpe/D7HRtBQl8aTg+ZmzFUAvu4lsv3eLXMLGFxWk=
 gopkg.in/mcuadros/go-syslog.v2 v2.2.1/go.mod h1:l5LPIyOOyIdQquNg+oU6Z3524YwrcqEm0aKH+5zpt2U=
 gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
 gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
 gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 gopkg.in/square/go-jose.v2 v2.3.1 h1:SK5KegNXmKmqE342YYN2qPHEnUYeoMiXXl1poUlI+o4=
 gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
+gopkg.in/square/go-jose.v2 v2.4.1 h1:H0TmLt7/KmzlrDOpa1F+zr0Tk90PbJYBfsVUmRLrf9Y=
+gopkg.in/square/go-jose.v2 v2.4.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV4fzYhNBql77zY0ykqs=
@@ -1489,6 +1568,7 @@ gopkg.in/yaml.v3 v3.0.0-20190905181640-827449938966/go.mod h1:K4uyk7z7BCEPqu6E+C
 gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools v0.0.0-20181223230014-1083505acf35/go.mod h1:R//lfYlUuTOTfblYI3lGoAAAebUdzjvbmQsuB7Ykd90=
 gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
 gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
 gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
@@ -1596,6 +1676,7 @@ sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:w
 sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw=
 sigs.k8s.io/structured-merge-diff/v3 v3.0.0 h1:dOmIZBMfhcHS09XZkMyUgkq5trg3/jRyJYFZUiaOp8E=
 sigs.k8s.io/structured-merge-diff/v3 v3.0.0/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw=
+sigs.k8s.io/testing_frameworks v0.1.2 h1:vK0+tvjF0BZ/RYFeZ1E6BYBwHJJXhjuZ3TdsEKH+UQM=
 sigs.k8s.io/testing_frameworks v0.1.2/go.mod h1:ToQrwSC3s8Xf/lADdZp3Mktcql9CG0UAmdJG9th5i0w=
 sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
 sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q=
diff --git a/kotsadm/go.mod b/kotsadm/go.mod
index cfdb61812..c52d3be84 100644
--- a/kotsadm/go.mod
+++ b/kotsadm/go.mod
@@ -11,18 +11,23 @@ require (
 	github.com/bitnami-labs/sealed-secrets v0.12.5
 	github.com/containerd/containerd v1.3.2
 	github.com/containers/image/v5 v5.5.2
+	github.com/coreos/go-oidc v2.2.1+incompatible
 	github.com/deislabs/oras v0.8.1
+	github.com/dexidp/dex v2.13.0+incompatible
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/docker/distribution v2.7.1+incompatible
 	github.com/docker/go-units v0.4.0
 	github.com/emicklei/go-restful v2.10.0+incompatible // indirect
+	github.com/ghodss/yaml v1.0.0
 	github.com/go-git/go-git/v5 v5.2.0
 	github.com/go-logfmt/logfmt v0.4.0
 	github.com/go-openapi/spec v0.19.5 // indirect
+	github.com/golang/gddo v0.0.0-20200831202555-721e228c7686
 	github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 // indirect
 	github.com/gorilla/mux v1.7.4
 	github.com/gorilla/websocket v1.4.0
 	github.com/gosimple/slug v1.9.0
+	github.com/gtank/cryptopasta v0.0.0-20170601214702-1f550f6f2f69 // indirect
 	github.com/hashicorp/go-version v1.2.0 // indirect
 	github.com/lib/pq v1.3.0
 	github.com/marccampbell/yaml-toolbox v0.0.0-20200805160637-950ceb36c770
@@ -30,7 +35,7 @@ require (
 	github.com/opencontainers/image-spec v1.0.2-0.20190823105129-775207bd45b6
 	github.com/pkg/errors v0.9.1
 	github.com/replicatedhq/kots v0.0.0-00010101000000-000000000000
-	github.com/replicatedhq/troubleshoot v0.9.51
+	github.com/replicatedhq/troubleshoot v0.9.52
 	github.com/replicatedhq/yaml/v3 v3.0.0-beta5-replicatedhq
 	github.com/robfig/cron v1.1.0
 	github.com/robfig/cron/v3 v3.0.0
@@ -42,8 +47,9 @@ require (
 	go.uber.org/multierr v1.3.0
 	go.uber.org/zap v1.13.0
 	golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5
+	golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
 	golang.org/x/tools v0.0.0-20200319210407-521f4a0cd458 // indirect
-	google.golang.org/api v0.9.0
+	google.golang.org/api v0.15.0
 	gopkg.in/ini.v1 v1.51.0
 	gopkg.in/yaml.v2 v2.3.0
 	k8s.io/api v0.18.4
diff --git a/kotsadm/go.sum b/kotsadm/go.sum
index e96638df4..603edbcf6 100644
--- a/kotsadm/go.sum
+++ b/kotsadm/go.sum
@@ -1,5 +1,6 @@
 bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8=
 bitbucket.org/bertimus9/systemstat v0.0.0-20180207000608-0eeff89b0690/go.mod h1:Ulb78X89vxKYgdL24HMTiXYHlyHEvruOj1ZPlqeNEZM=
+cloud.google.com/go v0.16.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.36.0/go.mod h1:RUoy9p/M4ge0HzT8L+SDZ8jg+Q6fth0CiBuhFJpSV40=
@@ -88,6 +89,7 @@ github.com/Masterminds/sprig/v3 v3.1.0/go.mod h1:ONGMf7UfYGAbMXCZmQLy8x3lCDIPrEZ
 github.com/Masterminds/squirrel v1.2.0 h1:K1NhbTO21BWG47IVR0OnIZuE0LZcXAYqywrC3Ko53KI=
 github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
 github.com/Masterminds/vcs v1.13.1/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA=
+github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
 github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
 github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5 h1:ygIc8M6trr62pF5DucadTWGdEB4mEyvzi0e2nbcmcyA=
 github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
@@ -153,6 +155,8 @@ github.com/bazelbuild/bazel-gazelle v0.19.1-0.20191105222053-70208cbdc798/go.mod
 github.com/bazelbuild/buildtools v0.0.0-20190731111112-f720930ceb60/go.mod h1:5JP0TXzWDHXv8qvxRC4InIazwdyDseBDbzESUMKk1yU=
 github.com/bazelbuild/buildtools v0.0.0-20190917191645-69366ca98f89/go.mod h1:5JP0TXzWDHXv8qvxRC4InIazwdyDseBDbzESUMKk1yU=
 github.com/bazelbuild/rules_go v0.0.0-20190719190356-6dae44dc5cab/go.mod h1:MC23Dc/wkXEyk3Wpq6lCqz0ZAYOZDw2DR5y3N1q2i7M=
+github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
+github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
 github.com/beevik/ntp v0.2.0 h1:sGsd+kAXzT0bfVfzJfce04g+dSRfrs+tbQW8lweuYgw=
 github.com/beevik/ntp v0.2.0/go.mod h1:hIHWr+l3+/clUnF44zdK+CWW7fO8dR5cIylAQ76NRpg=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -179,6 +183,7 @@ github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dR
 github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
 github.com/bombsimon/wsl v1.2.5/go.mod h1:43lEF/i0kpXbLCeDXL9LMT8c92HyBywXb0AsgMHYngM=
 github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
+github.com/bradfitz/gomemcache v0.0.0-20170208213004-1952afaa557d/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
 github.com/bshuster-repo/logrus-logstash-hook v0.4.1 h1:pgAtgj+A31JBVtEHu2uHuEx0n+2ukqUJnS2vVe5pQNA=
 github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
 github.com/bugsnag/bugsnag-go v1.5.3 h1:yeRUT3mUE13jL1tGwvoQsKdVbAsQx9AJ+fqahKveP04=
@@ -187,6 +192,7 @@ github.com/bugsnag/panicwrap v1.2.0 h1:OzrKrRvXis8qEvOkfcxNcYbOd2O7xXS2nnKMEMABF
 github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
 github.com/caddyserver/caddy v1.0.3/go.mod h1:G+ouvOY32gENkJC+jhgl62TyhvqEsFaDiZ4uw0RzP1E=
 github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
+github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/prettybench v0.0.0-20150116022406-03b8cfe5406c/go.mod h1:Xe6ZsFhtM8HrDku0pxJ3/Lr51rwykrzgFwpmTzleatY=
 github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
@@ -250,6 +256,8 @@ github.com/coreos/etcd v3.3.10+incompatible h1:jFneRYjIvLMLhDLCzuTuU4rSJUjRplcJQ
 github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
 github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
 github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
+github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk=
+github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
 github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
 github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
 github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
@@ -257,6 +265,7 @@ github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7
 github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e h1:Wf6HqHfScWJN9/ZjdUKyjop4mf3Qdd+1TvvltAvM3m8=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f h1:lBNOc5arjvs8E5mO2tbpBpLoyyu8B6e44T7hJy6potg=
@@ -275,6 +284,10 @@ github.com/daviddengcn/go-colortext v0.0.0-20160507010035-511bcaf42ccd/go.mod h1
 github.com/deislabs/oras v0.8.1 h1:If674KraJVpujYR00rzdi0QAmW4BxzMJPVAZJKuhQ0c=
 github.com/deislabs/oras v0.8.1/go.mod h1:Mx0rMSbBNaNfY9hjpccEnxkOqJL6KGjtxNHPLC4G4As=
 github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
+github.com/dexidp/dex v0.0.0-20201105145354-71bbbee07527/go.mod h1:qDmsfKCzXF8yAatFeruAXk+WmGhduJqTBdYyUU7Sx/A=
+github.com/dexidp/dex v2.13.0+incompatible h1:EQPpzCi51omkwBe0KYpRGaV3rk6CVvjcqeMGCe3Q00w=
+github.com/dexidp/dex v2.13.0+incompatible/go.mod h1:cRGkPWqKhDD1FMCICe2JbYDdVR2xGLa38F6iuH/jNAs=
+github.com/dexidp/dex/api/v2 v2.0.0/go.mod h1:k5arBJT1QYvpsEY3sEd0NXJp3hKWKuUUfzJ3BlcqPdM=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
@@ -336,17 +349,21 @@ github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwo
 github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=
+github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
 github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
 github.com/frankban/quicktest v1.7.2 h1:2QxQoC1TS09S7fhCPsrvqYdvP1H5M1P1ih5ABm3BTYk=
 github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o=
+github.com/fsnotify/fsnotify v1.4.3-0.20170329110642-4da3e2cfbabc/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
 github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa h1:RDBNVkRviHZtvDvId8XSGPu3rmpmSe+wKRcEWNgsfWU=
 github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
+github.com/garyburd/redigo v1.1.1-0.20170914051019-70e1b1943d4f/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
 github.com/garyburd/redigo v1.6.0 h1:0VruCpn7yAIIu7pWVClQC8wxCJEcG3nyzpMSHKi1PQc=
 github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
 github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@@ -433,12 +450,16 @@ github.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+
 github.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA=
 github.com/go-openapi/validate v0.19.5/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85nY1c2z52x1Gk4=
 github.com/go-ozzo/ozzo-validation v3.5.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU=
+github.com/go-redis/redis v6.15.6+incompatible h1:H9evprGPLI8+ci7fxQx6WNZHJSb7be8FqJQRhdQZ5Sg=
+github.com/go-redis/redis v6.15.6+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
 github.com/go-redis/redis/v7 v7.2.0 h1:CrCexy/jYWZjW0AyVoHlcJUeZN19VWlbepTh1Vq6dJs=
 github.com/go-redis/redis/v7 v7.2.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
 github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
+github.com/go-stack/stack v1.6.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
 github.com/go-toolsmith/astcast v1.0.0/go.mod h1:mt2OdQTeAQcY4DQgPSArJjHCcOwlX+Wl/kwN+LbLGQ4=
 github.com/go-toolsmith/astcopy v1.0.0/go.mod h1:vrgyG+5Bxrnz4MZWPF+pI4R8h3qKRjjyvV/DSez4WVQ=
@@ -473,19 +494,24 @@ github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
 github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84=
 github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
 github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls=
 github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
 github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
+github.com/golang/gddo v0.0.0-20200831202555-721e228c7686 h1:5vu7C+63KTbsSNnLhrgB98Sqy8MNVSW8FdhkcWA/3Rk=
+github.com/golang/gddo v0.0.0-20200831202555-721e228c7686/go.mod h1:sam69Hju0uq+5uvLJUMDlsKlQ21Vrs1Kd/1YFPNYdOU=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20180513044358-24b0969c4cb7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef h1:veQD95Isof8w9/WXiA+pa3tz3fJXkt5B7QaRBrM62gk=
 github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 h1:uHTyIjqVhYRhLbJ8nIiOJHkEZZ+5YoOsAbD3sk82NiE=
 github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/lint v0.0.0-20170918230701-e5d664eb928e/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
 github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3 h1:I4BOK3PBMjhWfQM2zPJKK7lOBGsrsvOB7kBELP33hiE=
 github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
 github.com/golang/mock v1.0.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
@@ -510,6 +536,7 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
 github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
 github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/snappy v0.0.0-20170215233205-553a64147049/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
 github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/golangci/check v0.0.0-20180506172741-cfe4005ccda2/go.mod h1:k9Qvh+8juN+UKMCS/3jFtGICgW8O96FVaZsaxdzDkR4=
@@ -540,6 +567,7 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z
 github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo=
 github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/cadvisor v0.35.0/go.mod h1:1nql6U13uTHaLYB8rLS5x9IJc2qT6Xd/Tr1sTX6NE48=
+github.com/google/go-cmp v0.1.1-0.20171103154506-982329095285/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
@@ -587,7 +615,11 @@ github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:
 github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
 github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg=
 github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
+github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
+github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
+github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
 github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
+github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
 github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
 github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
 github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
@@ -601,6 +633,7 @@ github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
 github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo=
 github.com/gotestyourself/gotestyourself v2.2.0+incompatible h1:AQwinXlbQR2HvPjQZOmDhRqsv5mZf+Jb1RnSLxcqZcI=
 github.com/gotestyourself/gotestyourself v2.2.0+incompatible/go.mod h1:zZKM6oeNM8k+FRljX1mnzVYeS8wiGgQyvST1/GafPbY=
+github.com/gregjones/httpcache v0.0.0-20170920190843-316c5e0ff04e/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
 github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM=
 github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
 github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
@@ -612,6 +645,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpg
 github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
 github.com/grpc-ecosystem/grpc-gateway v1.9.5 h1:UImYN5qQ8tuGpGE16ZmjvcTtTw24zw1QAp/SlnNrZhI=
 github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
+github.com/gtank/cryptopasta v0.0.0-20170601214702-1f550f6f2f69 h1:7xsUJsB2NrdcttQPa7JLEaGzvdbk7KvfrjgHZXOQRo0=
+github.com/gtank/cryptopasta v0.0.0-20170601214702-1f550f6f2f69/go.mod h1:YLEMZOtU+AZ7dhN9T/IpGhXVGly2bvkJQ+zxj3WeVQo=
 github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -639,6 +674,7 @@ github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
 github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+github.com/hashicorp/hcl v0.0.0-20170914154624-68e816d1c783/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w=
 github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w=
 github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
@@ -656,6 +692,8 @@ github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ=
 github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
 github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg=
 github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
+github.com/inconshreveable/log15 v0.0.0-20170622235902-74a0988b5f80 h1:g/SJtZVYc1cxSB8lgrgqeOlIdi4MhqNNHYRAC8y+g4c=
+github.com/inconshreveable/log15 v0.0.0-20170622235902-74a0988b5f80/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o=
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
@@ -672,6 +710,8 @@ github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
 github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
 github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo=
 github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
+github.com/jonboulle/clockwork v0.2.0 h1:J2SLSdy7HgElq8ekSl2Mxh6vrRNFxqbXGenYH2I02Vs=
+github.com/jonboulle/clockwork v0.2.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
 github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -731,6 +771,7 @@ github.com/kubernetes-csi/csi-test v2.0.0+incompatible/go.mod h1:YxJ4UiuPWIhMBkx
 github.com/kubernetes-csi/external-snapshotter/v2 v2.2.0-rc1 h1:/71hejlH+46ySQJf7iZMOnuYaZNVNXq1lx6vIPqgUFE=
 github.com/kubernetes-csi/external-snapshotter/v2 v2.2.0-rc1/go.mod h1:ArR2bKKq4dEfoRwRH69kt/xYOliLUlaqCZ+LM1teU8Q=
 github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
 github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
 github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
@@ -752,6 +793,7 @@ github.com/lucas-clemente/quic-go v0.10.2/go.mod h1:hvaRS9IHjFLMq76puFJeWNfmn+H7
 github.com/lucas-clemente/quic-go-certificates v0.0.0-20160823095156-d2f86524cced/go.mod h1:NCcRLrOTZbzhZvixZLlERbJtDtYsmMw8Jc4vS8Z0g58=
 github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw=
 github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
+github.com/magiconair/properties v1.7.4-0.20170902060319-8d7837e64d3c/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
 github.com/magiconair/properties v1.7.6/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
 github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
@@ -771,9 +813,11 @@ github.com/marccampbell/yaml-toolbox v0.0.0-20200805160637-950ceb36c770/go.mod h
 github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk=
 github.com/matoous/godox v0.0.0-20190911065817-5d6d842e92eb/go.mod h1:1BELzlh859Sh1c6+90blK8lbYy0kwQf1bYlBhBysy1s=
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-colorable v0.0.10-0.20170816031813-ad5389df28cd/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
 github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
 github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
 github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-isatty v0.0.2/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
@@ -792,6 +836,7 @@ github.com/mattn/go-shellwords v1.0.5/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vq
 github.com/mattn/go-shellwords v1.0.10 h1:Y7Xqm8piKOO3v10Thp7Z36h4FYFjt5xB//6XvOrs2Gw=
 github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
 github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
+github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
 github.com/mattn/go-sqlite3 v1.12.0 h1:u/x3mp++qUxvYfulZ4HKOvVO0JWhk7HtE8lWhbGz/Do=
 github.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
 github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
@@ -821,6 +866,7 @@ github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eI
 github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
 github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4=
 github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
+github.com/mitchellh/mapstructure v0.0.0-20170523030023-d0303fe80992/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/mapstructure v0.0.0-20180220230111-00c29f56e238/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
@@ -836,6 +882,7 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
 github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/mohae/deepcopy v0.0.0-20170603005431-491d3605edfb/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
+github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 github.com/mozilla/tls-observatory v0.0.0-20180409132520-8791a200eb40/go.mod h1:SrKMQvPiws7F7iqYp8/TX+IhxCYhzr6N/1yb8cwHsGk=
 github.com/mozilla/tls-observatory v0.0.0-20190404164649-a3c1b6cfecfd/go.mod h1:SrKMQvPiws7F7iqYp8/TX+IhxCYhzr6N/1yb8cwHsGk=
@@ -926,6 +973,7 @@ github.com/otiai10/copy v1.0.2/go.mod h1:c7RpqBkwMom4bYTSkLSym4VSJz/XtncWRAj/J4P
 github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
 github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
 github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
+github.com/pelletier/go-toml v1.0.1-0.20170904195809-1d6b12b7cb29/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/pelletier/go-toml v1.1.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
@@ -947,6 +995,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
+github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU=
+github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
 github.com/pquerna/ffjson v0.0.0-20180717144149-af8b230fcd20/go.mod h1:YARuvh7BUWHNhzDq2OM5tzR2RiCcN2D7sapiKyCel/M=
 github.com/pquerna/ffjson v0.0.0-20181028064349-e517b90714f7 h1:gGBSHPOU7g8YjTbhwn+lvFm2VDEhhA+PwDIlstkgSxE=
 github.com/pquerna/ffjson v0.0.0-20181028064349-e517b90714f7/go.mod h1:YARuvh7BUWHNhzDq2OM5tzR2RiCcN2D7sapiKyCel/M=
@@ -961,6 +1011,7 @@ github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5Fsn
 github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
 github.com/prometheus/client_golang v1.2.1 h1:JnMpQc6ppsNgw9QPAGF6Dod479itz7lvlsMzzNayLOI=
 github.com/prometheus/client_golang v1.2.1/go.mod h1:XMU6Z2MjaRKVu/dC1qupJI9SiNkDYzz3xecMgSW/F+U=
+github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
 github.com/prometheus/client_golang v1.5.1 h1:bdHYieyGlH+6OLEk2YQha8THib30KP0/yD0YH9m6xcA=
 github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
@@ -1003,6 +1054,8 @@ github.com/replicatedhq/kurl/kurlkinds v0.0.0-20200617224429-55f493a72baf/go.mod
 github.com/replicatedhq/termui/v3 v3.1.1-0.20200811145416-f40076d26851/go.mod h1:JDxG6+uubnk9/BZ2yUsyAJJwlptjrnmB2MPF5d2Xe/8=
 github.com/replicatedhq/troubleshoot v0.9.51 h1:phfXvWN8n/QhD2uhDUHpkv7tEjyoSzoNrQUe3CGH/T4=
 github.com/replicatedhq/troubleshoot v0.9.51/go.mod h1:5nWpBzGFtgu/U3TxrWEYZQiSOkjzR04HpDMAIq6dCjE=
+github.com/replicatedhq/troubleshoot v0.9.52 h1:e+3PtQY5lqDtH79vOXiLZCqX2GdbiBTETkJl6rdPEy8=
+github.com/replicatedhq/troubleshoot v0.9.52/go.mod h1:5nWpBzGFtgu/U3TxrWEYZQiSOkjzR04HpDMAIq6dCjE=
 github.com/replicatedhq/yaml/v3 v3.0.0-beta5-replicatedhq h1:PwPggruelq2336c1Ayg5STFqgbn/QB1tWLQwrVlU7ZQ=
 github.com/replicatedhq/yaml/v3 v3.0.0-beta5-replicatedhq/go.mod h1:Txa7LopbYCU8aRgmNe0n+y/EPMz50NbCPcVVJBquwag=
 github.com/robfig/cron v1.1.0 h1:jk4/Hud3TTdcrJgUOBgsqrZBarcxl6ADIjSC2iniwLY=
@@ -1019,6 +1072,9 @@ github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE
 github.com/rubenv/sql-migrate v0.0.0-20200212082348-64f95ea68aa3 h1:xkBtI5JktwbW/vf4vopBbhYsRFTGfQWHYXzC0/qYwxI=
 github.com/rubenv/sql-migrate v0.0.0-20200212082348-64f95ea68aa3/go.mod h1:rtQlpHw+eR6UrqaS3kX1VYeaCxzCVdimDS7g5Ln4pPc=
 github.com/rubiojr/go-vhd v0.0.0-20160810183302-0bfd3b39853c/go.mod h1:DM5xW0nvfNNm2uytzsvhI3OnX8uzaRAg8UX/CnDqbto=
+github.com/russellhaering/goxmldsig v0.0.0-20180430223755-7acd5e4a6ef7/go.mod h1:Oz4y6ImuOQZxynhbSXk7btjEfNBtGlj2dcaOvXl2FSM=
+github.com/russellhaering/goxmldsig v1.1.0 h1:lK/zeJie2sqG52ZAlPNn1oBBqsIsEKypUUBGpYYF6lk=
+github.com/russellhaering/goxmldsig v1.1.0/go.mod h1:QK8GhXPB3+AfuCrfo0oRISa9NfzeCpWmxeGnqEpDF9o=
 github.com/russross/blackfriday v0.0.0-20170610170232-067529f716f4/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
 github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
@@ -1080,10 +1136,12 @@ github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:Udh
 github.com/sourcegraph/go-diff v0.5.1/go.mod h1:j2dHj3m8aZgQO8lMTcTnBcXkRRRqi34cd2MNlA9u1mE=
 github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/spf13/afero v0.0.0-20170901052352-ee1bd8ee15a1/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
 github.com/spf13/afero v1.1.0/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
 github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
 github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
+github.com/spf13/cast v1.1.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
 github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
@@ -1097,17 +1155,20 @@ github.com/spf13/cobra v0.0.7 h1:FfTH+vuMXOas8jmfb5/M7dzEYx7LpcLb7a0LPe34uOU=
 github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
 github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
 github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
+github.com/spf13/jwalterweatherman v0.0.0-20170901151539-12bd96e66386/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 github.com/spf13/jwalterweatherman v0.0.0-20180109140146-7c0cea34c8ec/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
 github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
 github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v0.0.0-20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.1-0.20170901120850-7aff26db30c1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.0.0/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM=
 github.com/spf13/viper v1.0.2/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM=
 github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
 github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
@@ -1137,6 +1198,7 @@ github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG
 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
 github.com/tchap/go-patricia v2.3.0+incompatible h1:GkY4dP3cEfEASBPPkWd+AmjYxhmDkqO9/zg7R0lSQRs=
 github.com/tchap/go-patricia v2.3.0+incompatible/go.mod h1:bmLyhP68RS6kStMGxByiQ23RP/odRBOTVjwp2cDyi6I=
+github.com/testcontainers/testcontainers-go v0.0.9/go.mod h1:0Qe9qqjNZgxHzzdHPWwmQ2D49FFO7920hLdJ4yUJXJI=
 github.com/thecodeteam/goscaleio v0.1.0/go.mod h1:68sdkZAsK8bvEwBlbQnlLS+xU+hvLYM/iQ8KXej1AwM=
 github.com/throttled/throttled v2.2.2+incompatible/go.mod h1:0BjlrEGQmvxps+HuXLsyRdqpSRvJpq0PNIsOtqP9Nos=
 github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
@@ -1342,6 +1404,7 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7
 golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7 h1:AeiKBIuRw3UomYXSbLy0Mc2dDLfdtbT/IVn4keq83P0=
 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/oauth2 v0.0.0-20170912212905-13449ad91cb2/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -1352,6 +1415,7 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr
 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw=
 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw=
+golang.org/x/sync v0.0.0-20170517211232-f52d1811a629/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1373,6 +1437,7 @@ golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5h
 golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181213200352-4d1cda033e06/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190122071731-054c452bb702/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190124100055-b90733256f2e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1427,6 +1492,7 @@ golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/time v0.0.0-20170424234030-8be79e1e0910/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
@@ -1436,6 +1502,7 @@ golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxb
 golang.org/x/tools v0.0.0-20170915040203-e531a2a1c15f/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20180810170437-e96c4e24768d/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -1491,6 +1558,7 @@ gonum.org/v1/gonum v0.6.2/go.mod h1:9mxDZsDKxgMAuccQkewq682L+0eCu4dCN2yonUJTCLU=
 gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
 gonum.org/v1/netlib v0.0.0-20190331212654-76723241ea4e/go.mod h1:kS+toOQn6AQKjmKJ7gzohV1XkqsFehRA2FbsbkopSuQ=
 gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
+google.golang.org/api v0.0.0-20170921000349-586095a6e407/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
 google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
 google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
 google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y=
@@ -1501,6 +1569,8 @@ google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E
 google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
 google.golang.org/api v0.9.0 h1:jbyannxz0XFD3zdjgrSUsaJbgpH4eTrkdhRChkHPfO8=
 google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.15.0 h1:yzlyyDW/J0w8yNFJIhiAJy4kq74S+1DOLdawELNxFMA=
+google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
 google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -1510,6 +1580,7 @@ google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpC
 google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc=
 google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20170918111702-1e559d0a00ee/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
@@ -1545,6 +1616,8 @@ gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20191105091915-95d230a53780 h1:CEBpW6C191eozfEuWdUmIAHn7lwlLxJ7HVdr2e2Tsrw=
 gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20191105091915-95d230a53780/go.mod h1:3HH7i1SgMqlzxCcBmUHW657sD4Kvv9sC3HpL3YukzwA=
+gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM=
+gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
@@ -1566,12 +1639,16 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
 gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
 gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
 gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU=
+gopkg.in/ldap.v2 v2.5.1/go.mod h1:oI0cpe/D7HRtBQl8aTg+ZmzFUAvu4lsv3eLXMLGFxWk=
 gopkg.in/mcuadros/go-syslog.v2 v2.2.1/go.mod h1:l5LPIyOOyIdQquNg+oU6Z3524YwrcqEm0aKH+5zpt2U=
 gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
 gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
 gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 gopkg.in/square/go-jose.v2 v2.3.1 h1:SK5KegNXmKmqE342YYN2qPHEnUYeoMiXXl1poUlI+o4=
 gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
+gopkg.in/square/go-jose.v2 v2.4.1 h1:H0TmLt7/KmzlrDOpa1F+zr0Tk90PbJYBfsVUmRLrf9Y=
+gopkg.in/square/go-jose.v2 v2.4.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV4fzYhNBql77zY0ykqs=
@@ -1593,6 +1670,7 @@ gopkg.in/yaml.v3 v3.0.0-20190905181640-827449938966/go.mod h1:K4uyk7z7BCEPqu6E+C
 gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools v0.0.0-20181223230014-1083505acf35/go.mod h1:R//lfYlUuTOTfblYI3lGoAAAebUdzjvbmQsuB7Ykd90=
 gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
 gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
 gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
diff --git a/kotsadm/kustomize/overlays/dev/deployment.yaml b/kotsadm/kustomize/overlays/dev/deployment.yaml
index feb74d01b..6d9133d48 100644
--- a/kotsadm/kustomize/overlays/dev/deployment.yaml
+++ b/kotsadm/kustomize/overlays/dev/deployment.yaml
@@ -49,7 +49,11 @@ spec:
             - name: API_ENDPOINT
               value: http://kotsadm.default.svc.cluster.local:3000
             - name: API_ADVERTISE_ENDPOINT
-              value: http://127.0.0.1:30065
+              value: http://127.0.0.1:30000
+            - name: KOTSADM_ENV
+              value: "dev"
+            - name: ENABLE_WEB_PROXY
+              value: "1"
             # - name: DISABLE_OUTBOUND_CONNECTIONS  
             #   value: "true"
       volumes:
@@ -61,3 +65,8 @@ spec:
           secret:
             secretName: kubelet-client-cert
             optional: true
+      hostAliases:
+        - ip: 172.17.0.1
+          hostnames:
+            - kotsadm.kotsadmdevenv.com
+            - dex.kotsadmdevenv.com
diff --git a/kotsadm/kustomize/overlays/dev/kustomization.yaml b/kotsadm/kustomize/overlays/dev/kustomization.yaml
index 34a56f94f..777aaa841 100644
--- a/kotsadm/kustomize/overlays/dev/kustomization.yaml
+++ b/kotsadm/kustomize/overlays/dev/kustomization.yaml
@@ -1,8 +1,6 @@
 bases:
   - ../../base
 
-resources:
-  - ./nodeport.yaml
-
 patches:
   - ./deployment.yaml
+  - ./service.yaml
diff --git a/kotsadm/kustomize/overlays/dev/nodeport.yaml b/kotsadm/kustomize/overlays/dev/nodeport.yaml
deleted file mode 100644
index f7f2a07ed..000000000
--- a/kotsadm/kustomize/overlays/dev/nodeport.yaml
+++ /dev/null
@@ -1,15 +0,0 @@
-apiVersion: v1
-kind: Service
-metadata:
-  name: kotsadm-api-lb
-  labels:
-    app: kotsadm
-spec:
-  type: NodePort
-  ports:
-  - name: http
-    port: 8065
-    targetPort: http
-    nodePort: 30065
-  selector:
-    app: kotsadm
diff --git a/kotsadm/kustomize/overlays/dev/service.yaml b/kotsadm/kustomize/overlays/dev/service.yaml
new file mode 100644
index 000000000..e4cf5f226
--- /dev/null
+++ b/kotsadm/kustomize/overlays/dev/service.yaml
@@ -0,0 +1,11 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: kotsadm
+spec:
+  type: NodePort
+  ports:
+  - name: http
+    port: 3000
+    targetPort: http
+    nodePort: 30000
diff --git a/kotsadm/operator/Makefile b/kotsadm/operator/Makefile
index c66569b08..ceb8f89b9 100644
--- a/kotsadm/operator/Makefile
+++ b/kotsadm/operator/Makefile
@@ -33,7 +33,7 @@ build:
 
 .PHONY: run
 run: bin
-	./bin/kotsadm-operator --api-endpoint http://localhost:30065 --token local
+	./bin/kotsadm-operator --api-endpoint http://localhost:30000 --token local
 
 .PHONY: build-ttl.sh
 build-ttl.sh:
diff --git a/kotsadm/operator/go.mod b/kotsadm/operator/go.mod
index 158706a87..41b820649 100644
--- a/kotsadm/operator/go.mod
+++ b/kotsadm/operator/go.mod
@@ -4,12 +4,14 @@ go 1.14
 
 require (
 	github.com/blang/semver v3.5.1+incompatible
+	github.com/google/martian v2.1.0+incompatible
+	github.com/gorilla/mux v1.8.0
 	github.com/gorilla/websocket v1.4.0
 	github.com/hashicorp/logutils v1.0.0 // indirect
 	github.com/mitchellh/hashstructure v1.0.0
 	github.com/pact-foundation/pact-go v1.0.0-beta.5
 	github.com/pkg/errors v0.9.1
-	github.com/replicatedhq/troubleshoot v0.9.51
+	github.com/replicatedhq/troubleshoot v0.9.52
 	github.com/spf13/cobra v0.0.5
 	github.com/spf13/viper v1.4.0
 	gopkg.in/yaml.v2 v2.3.0
diff --git a/kotsadm/operator/go.sum b/kotsadm/operator/go.sum
index 039afe260..2c378db10 100644
--- a/kotsadm/operator/go.sum
+++ b/kotsadm/operator/go.sum
@@ -208,6 +208,7 @@ github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g=
 github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
 github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE=
@@ -227,6 +228,8 @@ github.com/googleapis/gnostic v0.3.1/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1a
 github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
 github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU=
+github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
 github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
 github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
 github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
@@ -403,6 +406,8 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T
 github.com/replicatedhq/termui/v3 v3.1.1-0.20200811145416-f40076d26851/go.mod h1:JDxG6+uubnk9/BZ2yUsyAJJwlptjrnmB2MPF5d2Xe/8=
 github.com/replicatedhq/troubleshoot v0.9.51 h1:phfXvWN8n/QhD2uhDUHpkv7tEjyoSzoNrQUe3CGH/T4=
 github.com/replicatedhq/troubleshoot v0.9.51/go.mod h1:5nWpBzGFtgu/U3TxrWEYZQiSOkjzR04HpDMAIq6dCjE=
+github.com/replicatedhq/troubleshoot v0.9.52 h1:e+3PtQY5lqDtH79vOXiLZCqX2GdbiBTETkJl6rdPEy8=
+github.com/replicatedhq/troubleshoot v0.9.52/go.mod h1:5nWpBzGFtgu/U3TxrWEYZQiSOkjzR04HpDMAIq6dCjE=
 github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
 github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc=
 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
diff --git a/kotsadm/operator/pkg/client/client.go b/kotsadm/operator/pkg/client/client.go
index c2068ddcd..3d72af2e8 100644
--- a/kotsadm/operator/pkg/client/client.go
+++ b/kotsadm/operator/pkg/client/client.go
@@ -19,6 +19,7 @@ import (
 	"github.com/replicatedhq/kots/kotsadm/operator/pkg/appstate/types"
 	"github.com/replicatedhq/kots/kotsadm/operator/pkg/socket"
 	"github.com/replicatedhq/kots/kotsadm/operator/pkg/socket/transport"
+	"github.com/replicatedhq/kots/kotsadm/operator/pkg/supportbundle"
 	"github.com/replicatedhq/kots/kotsadm/operator/pkg/util"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/rest"
@@ -82,6 +83,8 @@ type Client struct {
 func (c *Client) Run() error {
 	log.Println("Starting kotsadm-operator loop")
 
+	supportbundle.StartServer()
+
 	c.runHooksInformer()
 	defer c.shutdownHooksInformer()
 
diff --git a/kotsadm/operator/pkg/client/gvkn.go b/kotsadm/operator/pkg/client/gvkn.go
index 0c1cb620a..dbf42e73b 100644
--- a/kotsadm/operator/pkg/client/gvkn.go
+++ b/kotsadm/operator/pkg/client/gvkn.go
@@ -3,6 +3,7 @@ package client
 import (
 	"context"
 	"fmt"
+	"strings"
 
 	"github.com/pkg/errors"
 	"gopkg.in/yaml.v2"
@@ -46,7 +47,11 @@ func IsCRD(content []byte) bool {
 		return false
 	}
 
-	return o.APIVersion == "apiextensions.k8s.io/v1beta1" && o.Kind == "CustomResourceDefinition"
+	if o.Kind == "CustomResourceDefinition" {
+		return strings.HasPrefix(o.APIVersion, "apiextensions.k8s.io/")
+	}
+
+	return false
 }
 
 func IsNamespace(content []byte) bool {
diff --git a/kotsadm/operator/pkg/supportbundle/server.go b/kotsadm/operator/pkg/supportbundle/server.go
new file mode 100644
index 000000000..c0ecfd940
--- /dev/null
+++ b/kotsadm/operator/pkg/supportbundle/server.go
@@ -0,0 +1,48 @@
+package supportbundle
+
+import (
+	"fmt"
+	"net/http"
+	"runtime/pprof"
+
+	"github.com/google/martian/log"
+	"github.com/gorilla/mux"
+)
+
+func StartServer() {
+	go func() {
+		port := 3030
+		r := mux.NewRouter()
+
+		r.HandleFunc("/goroutines", getGoRoutines)
+
+		srv := &http.Server{
+			Handler: r,
+			Addr:    fmt.Sprintf(":%d", port),
+		}
+
+		log.Infof("Starting suppotbundle server on port %d...", port)
+
+		err := srv.ListenAndServe()
+		log.Errorf("failed to run support bundle server: %v", err)
+	}()
+}
+
+func getGoRoutines(w http.ResponseWriter, r *http.Request) {
+	profile := pprof.Lookup("goroutine")
+	if profile == nil {
+		log.Errorf("failed to get goroutine info")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Disposition", "attachment; filename=goroutines.txt")
+	w.Header().Set("Content-Type", "text/plain")
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := profile.WriteTo(w, 2); err != nil {
+		log.Errorf("failed to get goroutine info: %v", err)
+		return
+	}
+}
diff --git a/kotsadm/operator/pkg/util/kubectl_version.go b/kotsadm/operator/pkg/util/kubectl_version.go
index 2928a5360..fca69ee97 100644
--- a/kotsadm/operator/pkg/util/kubectl_version.go
+++ b/kotsadm/operator/pkg/util/kubectl_version.go
@@ -10,6 +10,9 @@ import (
 )
 
 var knownKubectlVersions = []semver.Version{
+	semver.MustParse("1.19.3"),
+	semver.MustParse("1.18.10"),
+	semver.MustParse("1.17.13"),
 	semver.MustParse("1.16.3"),
 	semver.MustParse("1.14.9"),
 }
diff --git a/kotsadm/pkg/apiserver/server.go b/kotsadm/pkg/apiserver/server.go
index b17a06986..6f881c879 100644
--- a/kotsadm/pkg/apiserver/server.go
+++ b/kotsadm/pkg/apiserver/server.go
@@ -18,6 +18,7 @@ import (
 	"github.com/replicatedhq/kots/kotsadm/pkg/snapshotscheduler"
 	"github.com/replicatedhq/kots/kotsadm/pkg/socketservice"
 	"github.com/replicatedhq/kots/kotsadm/pkg/store"
+	"github.com/replicatedhq/kots/kotsadm/pkg/supportbundle"
 	"github.com/replicatedhq/kots/kotsadm/pkg/updatechecker"
 )
 
@@ -36,6 +37,8 @@ func Start() {
 
 	store.GetStore().RunMigrations()
 
+	supportbundle.StartServer()
+
 	if err := informers.Start(); err != nil {
 		log.Println("Failed to start informers", err)
 	}
@@ -57,29 +60,24 @@ func Start() {
 		}
 	}
 
-	u, err := url.Parse("http://kotsadm-api-node:3000")
-	if err != nil {
-		panic(err)
-	}
-	upstream := httputil.NewSingleHostReverseProxy(u)
-
 	r := mux.NewRouter()
 
 	r.Use(handlers.CorsMiddleware)
 	r.Methods("OPTIONS").HandlerFunc(handlers.CORS)
 
-	// proxy all graphql requests
-	r.Path("/graphql").Methods("POST").HandlerFunc(handlers.NodeProxy(upstream))
-
 	/**********************************************************************
 	* Unauthenticated routes
 	**********************************************************************/
 
 	r.HandleFunc("/healthz", handlers.Healthz)
 	r.HandleFunc("/api/v1/login", handlers.Login)
+	r.HandleFunc("/api/v1/login/info", handlers.GetLoginInfo)
 	r.HandleFunc("/api/v1/logout", handlers.Logout) // this route uses its own auth
 	r.Path("/api/v1/metadata").Methods("GET").HandlerFunc(handlers.Metadata)
 
+	r.HandleFunc("/api/v1/oidc/login", handlers.OIDCLogin)
+	r.HandleFunc("/api/v1/oidc/login/callback", handlers.OIDCLoginCallback)
+
 	r.Path("/api/v1/troubleshoot/{appId}/{bundleId}").Methods("PUT").HandlerFunc(handlers.UploadSupportBundle)
 	r.Path("/api/v1/troubleshoot/supportbundle/{bundleId}/redactions").Methods("PUT").HandlerFunc(handlers.SetSupportBundleRedactions)
 	r.Path("/api/v1/preflight/app/{appSlug}/sequence/{sequence}").Methods("POST").HandlerFunc(handlers.PostPreflightStatus)
@@ -125,7 +123,6 @@ func Start() {
 	sessionAuthRouter.Path("/api/v1/troubleshoot/supportbundle/{bundleId}/redactions").Methods("GET").HandlerFunc(handlers.GetSupportBundleRedactions)
 	sessionAuthRouter.Path("/api/v1/troubleshoot/supportbundle/{bundleId}/download").Methods("GET").HandlerFunc(handlers.DownloadSupportBundle)
 	sessionAuthRouter.Path("/api/v1/troubleshoot/supportbundle/app/{appId}/cluster/{clusterId}/collect").Methods("POST").HandlerFunc(handlers.CollectSupportBundle)
-	sessionAuthRouter.Path("/api/v1/troubleshoot/analyzebundle/{bundleId}").Methods("POST").HandlerFunc(handlers.NodeProxy(upstream))
 
 	// redactor routes
 	sessionAuthRouter.Path("/api/v1/redact/set").Methods("PUT").HandlerFunc(handlers.UpdateRedact)
@@ -239,6 +236,19 @@ func Start() {
 	if os.Getenv("DISABLE_SPA_SERVING") != "1" {
 		spa := handlers.SPAHandler{StaticPath: filepath.Join("web", "dist"), IndexPath: "index.html"}
 		r.PathPrefix("/").Handler(spa)
+	} else if os.Getenv("ENABLE_WEB_PROXY") == "1" { // for dev env
+		u, err := url.Parse("http://kotsadm-web:30000")
+		if err != nil {
+			panic(err)
+		}
+		upstream := httputil.NewSingleHostReverseProxy(u)
+		webProxy := func(upstream *httputil.ReverseProxy) func(http.ResponseWriter, *http.Request) {
+			return func(w http.ResponseWriter, r *http.Request) {
+				r.Header.Set("X-Forwarded-Host", r.Header.Get("Host"))
+				upstream.ServeHTTP(w, r)
+			}
+		}(upstream)
+		r.PathPrefix("/").HandlerFunc(webProxy)
 	}
 
 	srv := &http.Server{
diff --git a/kotsadm/pkg/dex/kotsadm.go b/kotsadm/pkg/dex/kotsadm.go
new file mode 100644
index 000000000..7c0ecc2be
--- /dev/null
+++ b/kotsadm/pkg/dex/kotsadm.go
@@ -0,0 +1,224 @@
+package dex
+
+import (
+	"context"
+	"net/http"
+	"os"
+	"sync"
+	"time"
+
+	oidc "github.com/coreos/go-oidc"
+	dexstorage "github.com/dexidp/dex/storage"
+	"github.com/ghodss/yaml"
+	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/kotsadm/pkg/k8s"
+	"github.com/replicatedhq/kots/pkg/identity"
+	dextypes "github.com/replicatedhq/kots/pkg/identity/types/dex"
+	kotsadmtypes "github.com/replicatedhq/kots/pkg/kotsadm/types"
+	"golang.org/x/oauth2"
+	corev1 "k8s.io/api/core/v1"
+	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+const (
+	DexStateSecretName = "kotsadm-dex-state"
+)
+
+var stateMtx sync.Mutex
+
+func GetKotsadmDexConfig() (*dextypes.Config, error) {
+	clientset, err := k8s.Clientset()
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get k8s client set")
+	}
+
+	secret, err := clientset.CoreV1().Secrets(os.Getenv("POD_NAMESPACE")).Get(context.TODO(), identity.DexSecretName, metav1.GetOptions{})
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get kotsadm-dex secret")
+	}
+
+	marshalledConfig := secret.Data["dexConfig.yaml"]
+	config := dextypes.Config{}
+	if err := yaml.Unmarshal(marshalledConfig, &config); err != nil {
+		return nil, errors.Wrap(err, "failed to unmarshal kotsadm dex config")
+	}
+
+	return &config, nil
+}
+
+func GetKotsadmOIDCProvider() (*oidc.Provider, error) {
+	dexConfig, err := GetKotsadmDexConfig()
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get kotsadm dex config")
+	}
+
+	ctx := oidc.ClientContext(context.Background(), http.DefaultClient)
+	provider, err := oidc.NewProvider(ctx, dexConfig.Issuer)
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to query provider %q", dexConfig.Issuer)
+	}
+
+	return provider, nil
+}
+
+func GetKotsadmOAuth2Config() (*oauth2.Config, error) {
+	dexConfig, err := GetKotsadmDexConfig()
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get kotsadm dex config")
+	}
+
+	provider, err := GetKotsadmOIDCProvider()
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get kotsadm oidc provider")
+	}
+
+	var kotsadmClient *dexstorage.Client
+	for _, client := range dexConfig.StaticClients {
+		if client.ID == "kotsadm" {
+			kotsadmClient = &client
+			break
+		}
+	}
+	if kotsadmClient == nil {
+		return nil, errors.New("kotsadm dex client not found")
+	}
+
+	oauth2Config := oauth2.Config{
+		ClientID:     kotsadmClient.ID,
+		ClientSecret: kotsadmClient.Secret,
+		Endpoint:     provider.Endpoint(),
+		Scopes:       getScopes(),
+		RedirectURL:  kotsadmClient.RedirectURIs[0],
+	}
+
+	return &oauth2Config, nil
+}
+
+func getScopes() []string {
+	// TODO "offline_access"
+	return []string{"openid", "profile", "email", "groups"}
+}
+
+func SetDexState(state string) error {
+	stateMtx.Lock()
+	defer stateMtx.Unlock()
+
+	secret := stateSecretResource(DexStateSecretName, state)
+
+	clientset, err := k8s.Clientset()
+	if err != nil {
+		return errors.Wrap(err, "failed to get k8s client set")
+	}
+
+	existingSecret, err := clientset.CoreV1().Secrets(os.Getenv("POD_NAMESPACE")).Get(context.TODO(), DexStateSecretName, metav1.GetOptions{})
+	if err != nil {
+		if !kuberneteserrors.IsNotFound(err) {
+			return errors.Wrap(err, "failed to get existing dex state secret")
+		}
+
+		_, err = clientset.CoreV1().Secrets(os.Getenv("POD_NAMESPACE")).Create(context.TODO(), secret, metav1.CreateOptions{})
+		if err != nil {
+			return errors.Wrap(err, "failed to create dex state secret")
+		}
+
+		return nil
+	}
+
+	existingSecret = updateStateSecret(existingSecret, secret)
+
+	_, err = clientset.CoreV1().Secrets(os.Getenv("POD_NAMESPACE")).Update(context.TODO(), existingSecret, metav1.UpdateOptions{})
+	if err != nil {
+		return errors.Wrap(err, "failed to update dex state secret")
+	}
+
+	return nil
+}
+
+func GetDexState(state string) (string, error) {
+	clientset, err := k8s.Clientset()
+	if err != nil {
+		return "", errors.Wrap(err, "failed to get k8s client set")
+	}
+
+	secret, err := clientset.CoreV1().Secrets(os.Getenv("POD_NAMESPACE")).Get(context.TODO(), DexStateSecretName, metav1.GetOptions{})
+	if err != nil {
+		return "", errors.Wrap(err, "failed to get dex state secret")
+	}
+
+	return string(secret.Data[state]), nil
+}
+
+func ResetDexState(state string) error {
+	stateMtx.Lock()
+	defer stateMtx.Unlock()
+
+	clientset, err := k8s.Clientset()
+	if err != nil {
+		return errors.Wrap(err, "failed to get k8s client set")
+	}
+
+	secret, err := clientset.CoreV1().Secrets(os.Getenv("POD_NAMESPACE")).Get(context.TODO(), DexStateSecretName, metav1.GetOptions{})
+	if err != nil {
+		return errors.Wrap(err, "failed to get dex state secret")
+	}
+
+	delete(secret.Data, state)
+
+	_, err = clientset.CoreV1().Secrets(os.Getenv("POD_NAMESPACE")).Update(context.TODO(), secret, metav1.UpdateOptions{})
+	if err != nil {
+		return errors.Wrap(err, "failed to update dex state secret")
+	}
+
+	return nil
+}
+
+func stateSecretResource(secretName, state string) *corev1.Secret {
+	return &corev1.Secret{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "v1",
+			Kind:       "Secret",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   secretName,
+			Labels: kotsadmtypes.GetKotsadmLabels(identity.DexAdditionalLabels),
+		},
+		Data: map[string][]byte{
+			state: []byte(time.Now().UTC().Format(time.RFC3339)),
+		},
+	}
+}
+
+func updateStateSecret(existingSecret, desiredSecret *corev1.Secret) *corev1.Secret {
+	existingSecret.Data = mergeMaps(existingSecret.Data, desiredSecret.Data)
+	expireOldDexStates(existingSecret)
+	return existingSecret
+}
+
+func expireOldDexStates(secret *corev1.Secret) error {
+	for s, t := range secret.Data {
+		stateTime, err := time.Parse(time.RFC3339, string(t))
+		if err != nil {
+			return errors.Wrap(err, "failed to parse state time")
+		}
+
+		ttlTime := time.Now().Add(-24 * time.Hour)
+		if stateTime.Before(ttlTime) {
+			delete(secret.Data, s)
+			continue
+		}
+	}
+
+	return nil
+}
+
+func mergeMaps(existing map[string][]byte, new map[string][]byte) map[string][]byte {
+	merged := existing
+	if merged == nil {
+		merged = make(map[string][]byte)
+	}
+	for key, value := range new {
+		merged[key] = value
+	}
+	return merged
+}
diff --git a/kotsadm/pkg/handlers/app.go b/kotsadm/pkg/handlers/app.go
index 7857fe2f7..5ea6aee55 100644
--- a/kotsadm/pkg/handlers/app.go
+++ b/kotsadm/pkg/handlers/app.go
@@ -373,7 +373,7 @@ func RemoveApp(w http.ResponseWriter, r *http.Request) {
 			}
 
 			if currentVersion != nil {
-				response.Error = fmt.Sprintf("version %d is deployed", currentVersion.Sequence)
+				response.Error = fmt.Sprintf("application %s is deployed and cannot be removed", appSlug)
 				logger.Error(errors.Wrap(err, response.Error))
 				JSON(w, http.StatusBadRequest, response)
 				return
diff --git a/kotsadm/pkg/handlers/license.go b/kotsadm/pkg/handlers/license.go
index 50a7090a3..7927f5b29 100644
--- a/kotsadm/pkg/handlers/license.go
+++ b/kotsadm/pkg/handlers/license.go
@@ -264,6 +264,30 @@ func UploadNewLicense(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	allLicenses, err := store.GetStore().GetAllAppLicenses()
+	if err != nil {
+		logger.Error(err)
+		uploadLicenseResponse.Error = err.Error()
+		JSON(w, 500, uploadLicenseResponse)
+		return
+	}
+
+	// check does license already exist
+	existingLicense, err := license.CheckDoesLicenseExists(allLicenses, uploadLicenseRequest.LicenseData)
+	if err != nil {
+		logger.Error(err)
+		uploadLicenseResponse.Error = err.Error()
+		JSON(w, 500, uploadLicenseResponse)
+		return
+	}
+
+	if existingLicense != nil {
+		uploadLicenseResponse.Error = "License already exists"
+		uploadLicenseResponse.Slug = existingLicense.Spec.AppSlug
+		JSON(w, 400, uploadLicenseResponse)
+		return
+	}
+
 	skipImagePush, err := kotsutil.IsImagesPushedSet(kotsadmtypes.KotsadmConfigMap)
 	if err != nil {
 		logger.Error(err)
@@ -489,4 +513,4 @@ func GetPlatformLicenseCompatibility(w http.ResponseWriter, r *http.Request) {
 
 	JSON(w, http.StatusOK, platformLicense)
 	return
-}
+}
\ No newline at end of file
diff --git a/kotsadm/pkg/handlers/login.go b/kotsadm/pkg/handlers/login.go
index 3a995790d..6072368f6 100644
--- a/kotsadm/pkg/handlers/login.go
+++ b/kotsadm/pkg/handlers/login.go
@@ -4,11 +4,23 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"os"
+	"time"
 
+	oidc "github.com/coreos/go-oidc"
+	"github.com/pkg/errors"
+	kotsadmdex "github.com/replicatedhq/kots/kotsadm/pkg/dex"
 	"github.com/replicatedhq/kots/kotsadm/pkg/logger"
 	"github.com/replicatedhq/kots/kotsadm/pkg/session"
+	sessiontypes "github.com/replicatedhq/kots/kotsadm/pkg/session/types"
 	"github.com/replicatedhq/kots/kotsadm/pkg/store"
 	"github.com/replicatedhq/kots/kotsadm/pkg/user"
+	usertypes "github.com/replicatedhq/kots/kotsadm/pkg/user/types"
+	"github.com/replicatedhq/kots/pkg/identity"
+	ingress "github.com/replicatedhq/kots/pkg/ingress"
+	"github.com/replicatedhq/kots/pkg/rbac"
+	"github.com/segmentio/ksuid"
+	"golang.org/x/oauth2"
 )
 
 type LoginRequest struct {
@@ -20,7 +32,26 @@ type LoginResponse struct {
 	Token string `json:"token,omitempty"`
 }
 
+type LoginMethod string
+
+const (
+	PasswordAuth    LoginMethod = "shared-password"
+	IdentityService LoginMethod = "identity-service"
+)
+
 func Login(w http.ResponseWriter, r *http.Request) {
+	identityConfig, err := identity.GetConfig(r.Context(), os.Getenv("POD_NAMESPACE"))
+	if err != nil {
+		logger.Error(err)
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	if identityConfig.Enabled && identityConfig.DisablePasswordAuth {
+		err := errors.New("password authentication disabled")
+		JSON(w, http.StatusForbidden, NewErrorResponse(err))
+		return
+	}
+
 	loginResponse := LoginResponse{}
 
 	loginRequest := LoginRequest{}
@@ -45,7 +76,10 @@ func Login(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	createdSession, err := store.GetStore().CreateSession(foundUser)
+	// TODO: super user permissions
+	roles := session.GetSessionRolesFromRBAC(nil, rbac.DefaultGroups, rbac.DefaultRoles, rbac.DefaultPolicies)
+
+	createdSession, err := store.GetStore().CreateSession(foundUser, nil, roles)
 	if err != nil {
 		logger.Error(err)
 		JSON(w, http.StatusInternalServerError, loginResponse)
@@ -63,3 +97,239 @@ func Login(w http.ResponseWriter, r *http.Request) {
 
 	JSON(w, http.StatusOK, loginResponse)
 }
+
+type OIDCLoginResponse struct {
+	AuthCodeURL string `json:"authCodeURL"`
+	Error       string `json:"error,omitempty"`
+}
+
+func OIDCLogin(w http.ResponseWriter, r *http.Request) {
+	oidcLoginResponse := OIDCLoginResponse{}
+
+	oauth2Config, err := kotsadmdex.GetKotsadmOAuth2Config()
+	if err != nil {
+		logger.Error(errors.Wrap(err, "failed to get kotsadm oauth2 config"))
+		oidcLoginResponse.Error = "failed to get kotsadm oauth2 config"
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	// generate a random state
+	state := ksuid.New().String()
+
+	// save the generated state to compare on callback
+	if err := kotsadmdex.SetDexState(state); err != nil {
+		oidcLoginResponse.Error = "failed to set dex state"
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	authCodeURL := oauth2Config.AuthCodeURL(state)
+
+	oidcLoginResponse.AuthCodeURL = authCodeURL
+
+	// return a response instead of a redirect because Dex doesn't allow redirects from different origins (CORS)
+	JSON(w, http.StatusOK, oidcLoginResponse)
+}
+
+func OIDCLoginCallback(w http.ResponseWriter, r *http.Request) {
+	oauth2Config, err := kotsadmdex.GetKotsadmOAuth2Config()
+	if err != nil {
+		logger.Error(errors.Wrap(err, "failed to get kotsadm oauth2 config"))
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	provider, err := kotsadmdex.GetKotsadmOIDCProvider()
+	if err != nil {
+		logger.Error(errors.Wrap(err, "failed to get kotsadm oidc provider"))
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	var token *oauth2.Token
+
+	switch r.Method {
+	case http.MethodGet:
+		// Authorization redirect callback from OAuth2 auth flow.
+		if errMsg := r.FormValue("error"); errMsg != "" {
+			logger.Error(errors.Wrapf(err, "%s: %s", errMsg, r.FormValue("error_description")))
+			w.WriteHeader(http.StatusBadRequest)
+			return
+		}
+
+		code := r.FormValue("code")
+		if code == "" {
+			logger.Error(errors.New("no code in request"))
+			w.WriteHeader(http.StatusBadRequest)
+			return
+		}
+
+		state := r.FormValue("state")
+		foundState, err := kotsadmdex.GetDexState(state)
+		if err != nil {
+			logger.Error(errors.Wrap(err, "failed to get saved dex state"))
+			w.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+
+		if foundState == "" {
+			logger.Error(errors.Errorf("invalid state %s", state))
+			w.WriteHeader(http.StatusBadRequest)
+			return
+		}
+
+		if err := kotsadmdex.ResetDexState(state); err != nil {
+			logger.Error(errors.Wrap(err, "failed to reset dex state"))
+			w.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+
+		ctx := oidc.ClientContext(r.Context(), http.DefaultClient)
+		token, err = oauth2Config.Exchange(ctx, code)
+		if err != nil {
+			logger.Error(errors.Wrap(err, "failed to exchange token"))
+			w.WriteHeader(http.StatusBadRequest)
+			return
+		}
+	case http.MethodPost:
+		// Form request from frontend to refresh a token.
+		refresh := r.FormValue("refresh_token")
+		if refresh == "" {
+			logger.Error(errors.New("no refresh_token in request"))
+			w.WriteHeader(http.StatusBadRequest)
+			return
+		}
+
+		ctx := oidc.ClientContext(r.Context(), http.DefaultClient)
+		t := &oauth2.Token{
+			RefreshToken: refresh,
+			Expiry:       time.Now().Add(-time.Hour),
+		}
+
+		token, err = oauth2Config.TokenSource(ctx, t).Token()
+		if err != nil {
+			logger.Error(errors.Wrap(err, "failed to get token"))
+			w.WriteHeader(http.StatusBadRequest)
+			return
+		}
+	default:
+		logger.Error(errors.Errorf("method not implemented: %s", r.Method))
+		w.WriteHeader(http.StatusBadRequest)
+		return
+	}
+
+	rawIDToken, ok := token.Extra("id_token").(string)
+	if !ok {
+		logger.Error(errors.New("no id_token in token response"))
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	verifier := provider.Verifier(&oidc.Config{ClientID: oauth2Config.ClientID})
+	idToken, err := verifier.Verify(r.Context(), rawIDToken)
+	if err != nil {
+		logger.Error(errors.Wrap(err, "failed to verify ID token"))
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	var claims struct {
+		Email    string   `json:"email"`
+		Name     string   `json:"name"`
+		Verified bool     `json:"email_verified"`
+		Groups   []string `json:"groups"`
+	}
+	if err := idToken.Claims(&claims); err != nil {
+		logger.Error(errors.Wrap(err, "error decoding ID token claims"))
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	user := &usertypes.User{
+		ID: claims.Email,
+	}
+
+	identityConfig, err := identity.GetConfig(r.Context(), os.Getenv("POD_NAMESPACE"))
+	if err != nil {
+		logger.Error(errors.Wrap(err, "failed to get identity config"))
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	roles := []sessiontypes.SessionRole{}
+	if identityConfig.EnableAdvancedRBAC {
+		roles = session.GetSessionRolesFromRBAC(claims.Groups, identityConfig.RBAC.Groups, identityConfig.RBAC.Roles, identityConfig.RBAC.Policies)
+	} else {
+		groups := rbac.DefaultGroups
+		if len(identityConfig.RestrictedGroups) > 0 {
+			groups = identity.RestrictedGroupsToRBACGroups(identityConfig.RestrictedGroups)
+		}
+		roles = session.GetSessionRolesFromRBAC(claims.Groups, groups, rbac.DefaultRoles, rbac.DefaultPolicies)
+	}
+
+	createdSession, err := store.GetStore().CreateSession(user, &idToken.Expiry, roles)
+	if err != nil {
+		logger.Error(errors.Wrap(err, "failed to create session"))
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	signedJWT, err := session.SignJWT(createdSession)
+	if err != nil {
+		logger.Error(errors.Wrap(err, "failed to sign jwt"))
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	responseToken := fmt.Sprintf("Bearer %s", signedJWT)
+
+	ingressConfig, err := ingress.GetConfig(r.Context(), os.Getenv("POD_NAMESPACE"))
+	if err != nil {
+		logger.Error(errors.Wrap(err, "failed to get ingress config"))
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	// TODO: support tls?
+	expire := time.Now().Add(30 * time.Minute)
+	cookie := http.Cookie{
+		Name:    "token",
+		Value:   responseToken,
+		Expires: expire,
+		Path:    "/",
+	}
+	http.SetCookie(w, &cookie)
+
+	http.Redirect(w, r, ingress.GetAddress(*ingressConfig), http.StatusSeeOther)
+}
+
+type GetLoginInfoResponse struct {
+	Method            LoginMethod `json:"method"`
+	IdentityConnector string      `json:"identityConnector,omitempty"` // TODO: support multiple connectors
+	Error             string      `json:"error,omitempty"`
+}
+
+func GetLoginInfo(w http.ResponseWriter, r *http.Request) {
+	getLoginInfoResponse := GetLoginInfoResponse{}
+
+	identityConfig, err := identity.GetConfig(r.Context(), os.Getenv("POD_NAMESPACE"))
+	if err != nil {
+		logger.Error(err)
+		getLoginInfoResponse.Error = "failed to get identity config"
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	if !identityConfig.Enabled {
+		getLoginInfoResponse.Method = PasswordAuth
+		JSON(w, http.StatusOK, getLoginInfoResponse)
+		return
+	}
+
+	getLoginInfoResponse.Method = IdentityService
+
+	if len(identityConfig.DexConnectors) > 0 {
+		getLoginInfoResponse.IdentityConnector = identityConfig.DexConnectors[0].Name
+	}
+
+	JSON(w, http.StatusOK, getLoginInfoResponse)
+}
diff --git a/kotsadm/pkg/handlers/session.go b/kotsadm/pkg/handlers/session.go
index b8d3731fe..036845ac4 100644
--- a/kotsadm/pkg/handlers/session.go
+++ b/kotsadm/pkg/handlers/session.go
@@ -3,12 +3,14 @@ package handlers
 import (
 	"context"
 	"encoding/base64"
+	"fmt"
 	"net/http"
 	"os"
 	"strings"
 
 	"github.com/pkg/errors"
 	"github.com/replicatedhq/kots/kotsadm/pkg/session"
+	"github.com/replicatedhq/kots/kotsadm/pkg/session/types"
 	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes"
@@ -66,14 +68,39 @@ func requireValidSession(w http.ResponseWriter, r *http.Request) error {
 
 	// we don't currently have roles, all valid tokens are valid sessions
 	if sess == nil || sess.ID == "" {
-		response := ErrorResponse{Error: "no session in auth header"}
+		err := errors.New("no session in auth header")
+		response := ErrorResponse{Error: err.Error()}
 		JSON(w, http.StatusUnauthorized, response)
-		return errors.Wrap(err, "empty session")
+		return err
+	}
+
+	if sess.HasRBAC { // handle pre-rbac sessions
+		resource := strings.TrimPrefix(r.URL.Path, "/api/v1")
+		if !sessionAuthorize(resource, sess.Roles) {
+			err := fmt.Errorf("access denied to resource %s", resource)
+			response := ErrorResponse{Error: err.Error()}
+			JSON(w, http.StatusForbidden, response)
+			return err
+		}
 	}
 
 	return nil
 }
 
+func sessionAuthorize(resource string, roles []types.SessionRole) bool {
+	// TODO
+	for _, role := range roles {
+		for _, policies := range role.Policies {
+			for _, allowed := range policies.Allowed {
+				if allowed == "**/*" {
+					return true
+				}
+			}
+		}
+	}
+	return false
+}
+
 func requireValidKOTSToken(w http.ResponseWriter, r *http.Request) error {
 	if r.Header.Get("Authorization") == "" {
 		w.WriteHeader(http.StatusUnauthorized)
diff --git a/kotsadm/pkg/license/license.go b/kotsadm/pkg/license/license.go
index 54735e76e..f371179ba 100644
--- a/kotsadm/pkg/license/license.go
+++ b/kotsadm/pkg/license/license.go
@@ -147,3 +147,27 @@ func GetCurrentLicenseString(a *apptypes.App) (string, error) {
 	}
 	return string(kotsLicense), nil
 }
+
+func CheckDoesLicenseExists(allLicenses []*kotsv1beta1.License, uploadedLicense string) (*kotsv1beta1.License, error) {
+	parsedUploadedLicense, err := GetParsedLicense(uploadedLicense)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to parse uploaded license")
+	}
+
+	for _, license := range allLicenses {
+		if license.Spec.LicenseID == parsedUploadedLicense.Spec.LicenseID {
+			return license, nil
+		}
+	}
+	return nil, nil
+}
+
+func GetParsedLicense(licenseStr string) (*kotsv1beta1.License, error) {
+	decode := scheme.Codecs.UniversalDeserializer().Decode
+	obj, _, err := decode([]byte(licenseStr), nil, nil)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to decode license yaml")
+	}
+	license := obj.(*kotsv1beta1.License)
+	return license, nil
+}
diff --git a/kotsadm/pkg/session/session.go b/kotsadm/pkg/session/session.go
index 027cdf02d..3988c64ac 100644
--- a/kotsadm/pkg/session/session.go
+++ b/kotsadm/pkg/session/session.go
@@ -11,6 +11,8 @@ import (
 	"github.com/pkg/errors"
 	"github.com/replicatedhq/kots/kotsadm/pkg/session/types"
 	"github.com/replicatedhq/kots/kotsadm/pkg/store"
+	"github.com/replicatedhq/kots/pkg/rbac"
+	rbactypes "github.com/replicatedhq/kots/pkg/rbac/types"
 	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes"
@@ -63,6 +65,9 @@ func Parse(signedToken string) (*types.Session, error) {
 			ID:        "kots-cli",
 			CreatedAt: time.Now(),
 			ExpiresAt: time.Now().Add(time.Minute),
+			// TODO: super user permissions
+			Roles:   GetSessionRolesFromRBAC(nil, rbac.DefaultGroups, rbac.DefaultRoles, rbac.DefaultPolicies),
+			HasRBAC: true,
 		}
 
 		return &s, nil
@@ -99,3 +104,46 @@ func SignJWT(s *types.Session) (string, error) {
 
 	return signedToken, nil
 }
+
+func GetSessionRolesFromRBAC(sessionGroupIDs []string, groups []rbactypes.Group, roles []rbactypes.Role, policies []rbactypes.Policy) []types.SessionRole {
+	var sessionRolesIDs []string
+	for _, group := range groups {
+		if group.ID == rbac.WildcardGroupID {
+			sessionRolesIDs = append(sessionRolesIDs, group.RoleIDs...)
+			continue
+		}
+		for _, groupID := range sessionGroupIDs {
+			if group.ID == groupID {
+				sessionRolesIDs = append(sessionRolesIDs, group.RoleIDs...)
+				break
+			}
+		}
+	}
+
+	var sessionRoles []types.SessionRole
+	for _, role := range roles {
+		for _, roleID := range sessionRolesIDs {
+			if role.ID == roleID {
+				sessionRole := types.SessionRole{
+					ID:       role.ID,
+					Policies: []types.SessionPolicy{},
+				}
+				for _, policy := range policies {
+					for _, policyID := range role.PolicyIDs {
+						if policy.ID == policyID {
+							sessionRole.Policies = append(sessionRole.Policies, types.SessionPolicy{
+								ID:      policy.ID,
+								Allowed: policy.Allowed,
+								Denied:  policy.Denied,
+							})
+							break
+						}
+					}
+				}
+				sessionRoles = append(sessionRoles, sessionRole)
+				break
+			}
+		}
+	}
+	return sessionRoles
+}
diff --git a/kotsadm/pkg/session/types/types.go b/kotsadm/pkg/session/types/types.go
index 38dc831e1..5902437b8 100644
--- a/kotsadm/pkg/session/types/types.go
+++ b/kotsadm/pkg/session/types/types.go
@@ -6,4 +6,17 @@ type Session struct {
 	ID        string
 	CreatedAt time.Time
 	ExpiresAt time.Time
+	Roles     []SessionRole
+	HasRBAC   bool
+}
+
+type SessionRole struct {
+	ID       string
+	Policies []SessionPolicy
+}
+
+type SessionPolicy struct {
+	ID      string
+	Allowed []string
+	Denied  []string
 }
diff --git a/kotsadm/pkg/snapshot/backup.go b/kotsadm/pkg/snapshot/backup.go
index 83fbc36c3..8f3b58f98 100644
--- a/kotsadm/pkg/snapshot/backup.go
+++ b/kotsadm/pkg/snapshot/backup.go
@@ -110,11 +110,17 @@ func CreateApplicationBackup(ctx context.Context, a *apptypes.App, isScheduled b
 		"kots.io/app-sequence":       strconv.FormatInt(parentSequence, 10),
 		"kots.io/snapshot-requested": time.Now().UTC().Format(time.RFC3339),
 	}
-	veleroBackup.Spec.LabelSelector = &metav1.LabelSelector{
+
+	labelSelector := metav1.LabelSelector{
 		MatchLabels: map[string]string{
 			"kots.io/app-slug": a.Slug,
 		},
 	}
+	if veleroBackup.Spec.LabelSelector != nil {
+		labelSelector = mergeLabelSelector(labelSelector, *veleroBackup.Spec.LabelSelector)
+	}
+	veleroBackup.Spec.LabelSelector = &labelSelector
+
 	veleroBackup.Spec.IncludedNamespaces = includedNamespaces
 
 	veleroBackup.Spec.StorageLocation = "default"
@@ -162,6 +168,11 @@ func CreateInstanceBackup(ctx context.Context, cluster *downstreamtypes.Downstre
 
 	appsSequences := map[string]int64{}
 	includedNamespaces := []string{kotsadmNamespace}
+	labelSelector := metav1.LabelSelector{
+		MatchLabels: map[string]string{
+			kotsadmtypes.BackupLabel: kotsadmtypes.BackupLabelValue,
+		},
+	}
 
 	for _, a := range apps {
 		downstreams, err := store.GetStore().ListDownstreamsForApp(a.ID)
@@ -200,6 +211,24 @@ func CreateInstanceBackup(ctx context.Context, cluster *downstreamtypes.Downstre
 			return nil, errors.Wrap(err, "failed to load kots kinds from path")
 		}
 
+		backupSpec, err := kotsKinds.Marshal("velero.io", "v1", "Backup")
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to get backup spec from kotskinds")
+		}
+
+		renderedBackup, err := helper.RenderAppFile(a, nil, []byte(backupSpec), kotsKinds)
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to render backup")
+		}
+		veleroBackup, err := kotsutil.LoadBackupFromContents(renderedBackup)
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to load backup from contents")
+		}
+
+		if veleroBackup.Spec.LabelSelector != nil {
+			labelSelector = mergeLabelSelector(labelSelector, *veleroBackup.Spec.LabelSelector)
+		}
+
 		includedNamespaces = append(includedNamespaces, kotsKinds.KotsApplication.Spec.AdditionalNamespaces...)
 	}
 
@@ -242,11 +271,7 @@ func CreateInstanceBackup(ctx context.Context, cluster *downstreamtypes.Downstre
 		Spec: velerov1.BackupSpec{
 			StorageLocation:    "default",
 			IncludedNamespaces: includedNamespaces,
-			LabelSelector: &metav1.LabelSelector{
-				MatchLabels: map[string]string{
-					kotsadmtypes.BackupLabel: kotsadmtypes.BackupLabelValue,
-				},
-			},
+			LabelSelector:      &labelSelector,
 		},
 	}
 
@@ -736,3 +761,16 @@ func downloadBackupLogs(veleroNamespace, backupName string) ([]types.SnapshotErr
 	errs, warnings, execs, err := parseLogs(gzipReader)
 	return errs, warnings, execs, err
 }
+
+func mergeLabelSelector(kots metav1.LabelSelector, app metav1.LabelSelector) metav1.LabelSelector {
+	for k, v := range app.MatchLabels {
+		if _, ok := kots.MatchLabels[k]; ok {
+			logger.Errorf("application label %s is already defined, skipping duplicate", k)
+			continue
+		}
+		kots.MatchLabels[k] = v
+	}
+
+	kots.MatchExpressions = append(kots.MatchExpressions, app.MatchExpressions...)
+	return kots
+}
diff --git a/kotsadm/pkg/store/ocistore/license_store.go b/kotsadm/pkg/store/ocistore/license_store.go
index e0da1f995..e1aaac0aa 100644
--- a/kotsadm/pkg/store/ocistore/license_store.go
+++ b/kotsadm/pkg/store/ocistore/license_store.go
@@ -39,3 +39,7 @@ func (s OCIStore) GetLicenseForAppVersion(appID string, sequence int64) (*kotsv1
 
 	return appVersion.KOTSKinds.License, nil
 }
+
+func (s OCIStore) GetAllAppLicenses() ([]*kotsv1beta1.License, error) {
+	return nil, ErrNotImplemented
+}
\ No newline at end of file
diff --git a/kotsadm/pkg/store/ocistore/session_store.go b/kotsadm/pkg/store/ocistore/session_store.go
index c184a97d9..11f3e1605 100644
--- a/kotsadm/pkg/store/ocistore/session_store.go
+++ b/kotsadm/pkg/store/ocistore/session_store.go
@@ -26,7 +26,7 @@ const (
 	SessionSecretName = "kotsadm-sessions"
 )
 
-func (s OCIStore) CreateSession(forUser *usertypes.User) (*sessiontypes.Session, error) {
+func (s OCIStore) CreateSession(forUser *usertypes.User, expiresAt *time.Time, roles []sessiontypes.SessionRole) (*sessiontypes.Session, error) {
 	logger.Debug("creating session")
 
 	randomID, err := ksuid.NewRandom()
@@ -44,7 +44,14 @@ func (s OCIStore) CreateSession(forUser *usertypes.User) (*sessiontypes.Session,
 	session := sessiontypes.Session{
 		ID:        id,
 		CreatedAt: time.Now(),
-		ExpiresAt: time.Now().AddDate(0, 0, 14),
+		Roles:     roles,
+		HasRBAC:   true,
+	}
+
+	if expiresAt != nil {
+		session.ExpiresAt = *expiresAt
+	} else {
+		session.ExpiresAt = time.Now().AddDate(0, 0, 14)
 	}
 
 	b, err := json.Marshal(session)
diff --git a/kotsadm/pkg/store/s3pg/license_store.go b/kotsadm/pkg/store/s3pg/license_store.go
index fb2d3040b..6b793d5fa 100644
--- a/kotsadm/pkg/store/s3pg/license_store.go
+++ b/kotsadm/pkg/store/s3pg/license_store.go
@@ -85,3 +85,32 @@ func (s S3PGStore) GetLicenseForAppVersion(appID string, sequence int64) (*kotsv
 
 	return nil, nil
 }
+
+func (s S3PGStore) GetAllAppLicenses() ([]*kotsv1beta1.License, error) {
+	db := persistence.MustGetPGSession()
+	query := `select license from app`
+	rows, err := db.Query(query)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to query")
+	}
+	defer rows.Close()
+
+	var licenseStr sql.NullString
+	licenses := []*kotsv1beta1.License{}
+	for rows.Next() {
+		if err := rows.Scan(&licenseStr); err != nil {
+			return nil, errors.Wrap(err, "failed to scan")
+		}
+		if licenseStr.Valid {
+			decode := scheme.Codecs.UniversalDeserializer().Decode
+			obj, _, err := decode([]byte(licenseStr.String), nil, nil)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to decode license yaml")
+			}
+			license := obj.(*kotsv1beta1.License)
+			licenses = append(licenses, license)
+		}
+	}
+
+	return licenses, nil
+}
\ No newline at end of file
diff --git a/kotsadm/pkg/store/s3pg/session_store.go b/kotsadm/pkg/store/s3pg/session_store.go
index 17cac9c9f..dcb5d1806 100644
--- a/kotsadm/pkg/store/s3pg/session_store.go
+++ b/kotsadm/pkg/store/s3pg/session_store.go
@@ -1,6 +1,7 @@
 package s3pg
 
 import (
+	"encoding/json"
 	"time"
 
 	"github.com/pkg/errors"
@@ -11,7 +12,11 @@ import (
 	"github.com/segmentio/ksuid"
 )
 
-func (s S3PGStore) CreateSession(forUser *usertypes.User) (*sessiontypes.Session, error) {
+type SessionMetadata struct {
+	Roles []sessiontypes.SessionRole
+}
+
+func (s S3PGStore) CreateSession(forUser *usertypes.User, expiresAt *time.Time, roles []sessiontypes.SessionRole) (*sessiontypes.Session, error) {
 	logger.Debug("creating session")
 
 	randomID, err := ksuid.NewRandom()
@@ -21,9 +26,19 @@ func (s S3PGStore) CreateSession(forUser *usertypes.User) (*sessiontypes.Session
 
 	id := randomID.String()
 
+	metadata, err := json.Marshal(SessionMetadata{Roles: roles})
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to marshal session metadata")
+	}
+
+	if expiresAt == nil {
+		e := time.Now().AddDate(0, 0, 14)
+		expiresAt = &e
+	}
+
 	db := persistence.MustGetPGSession()
 	query := `insert into session (id, user_id, metadata, expire_at) values ($1, $2, $3, $4)`
-	_, err = db.Exec(query, id, forUser.ID, "", time.Now().AddDate(0, 0, 14))
+	_, err = db.Exec(query, id, forUser.ID, string(metadata), *expiresAt)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to create session")
 	}
@@ -37,15 +52,25 @@ func (s S3PGStore) GetSession(id string) (*sessiontypes.Session, error) {
 	// 	zap.String("id", id))
 
 	db := persistence.MustGetPGSession()
-	query := `select id, expire_at from session where id = $1`
+	query := `select id, metadata, expire_at from session where id = $1`
 	row := db.QueryRow(query, id)
 	session := sessiontypes.Session{}
 
 	var expiresAt time.Time
-	if err := row.Scan(&session.ID, &expiresAt); err != nil {
+	var metadataStr string
+	if err := row.Scan(&session.ID, &metadataStr, &expiresAt); err != nil {
 		return nil, errors.Wrap(err, "failed to get session")
 	}
 
+	if metadataStr != "" {
+		metadata := SessionMetadata{}
+		if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil {
+			return nil, errors.Wrap(err, "failed to unmarshal session metadata")
+		}
+		session.HasRBAC = true
+		session.Roles = metadata.Roles
+	}
+
 	session.ExpiresAt = expiresAt
 
 	return &session, nil
diff --git a/kotsadm/pkg/store/store_interface.go b/kotsadm/pkg/store/store_interface.go
index 9e810c951..2f0e1f7bf 100644
--- a/kotsadm/pkg/store/store_interface.go
+++ b/kotsadm/pkg/store/store_interface.go
@@ -96,7 +96,7 @@ type TaskStore interface {
 }
 
 type SessionStore interface {
-	CreateSession(user *usertypes.User) (*sessiontypes.Session, error)
+	CreateSession(user *usertypes.User, expiresAt *time.Time, roles []sessiontypes.SessionRole) (*sessiontypes.Session, error)
 	DeleteSession(sessionID string) error
 	GetSession(sessionID string) (*sessiontypes.Session, error)
 }
@@ -150,8 +150,10 @@ type LicenseStore interface {
 	GetInitialLicenseForApp(appID string) (*kotsv1beta1.License, error)
 	GetLatestLicenseForApp(appID string) (*kotsv1beta1.License, error)
 	GetLicenseForAppVersion(appID string, sequence int64) (*kotsv1beta1.License, error)
+	GetAllAppLicenses() ([]*kotsv1beta1.License, error)
 }
 
+
 type ClusterStore interface {
 	ListClusters() ([]*downstreamtypes.Downstream, error)
 	GetClusterIDFromSlug(slug string) (clusterID string, err error)
diff --git a/kotsadm/pkg/supportbundle/server.go b/kotsadm/pkg/supportbundle/server.go
new file mode 100644
index 000000000..ef0d55448
--- /dev/null
+++ b/kotsadm/pkg/supportbundle/server.go
@@ -0,0 +1,49 @@
+package supportbundle
+
+import (
+	"fmt"
+	"net/http"
+	"runtime/pprof"
+
+	"github.com/gorilla/mux"
+	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/kotsadm/pkg/logger"
+)
+
+func StartServer() {
+	go func() {
+		port := 3030
+		r := mux.NewRouter()
+
+		r.HandleFunc("/goroutines", getGoRoutines)
+
+		srv := &http.Server{
+			Handler: r,
+			Addr:    fmt.Sprintf(":%d", port),
+		}
+
+		fmt.Printf("Starting suppotbundle server on port %d...\n", port)
+
+		err := srv.ListenAndServe()
+		logger.Error(errors.Wrap(err, "failed to run support bundle server"))
+	}()
+}
+
+func getGoRoutines(w http.ResponseWriter, r *http.Request) {
+	profile := pprof.Lookup("goroutine")
+	if profile == nil {
+		logger.Errorf("failed to get goroutine info")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Disposition", "attachment; filename=goroutines.txt")
+	w.Header().Set("Content-Type", "text/plain")
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := profile.WriteTo(w, 2); err != nil {
+		logger.Error(errors.Wrap(err, "failed to get goroutine info"))
+		return
+	}
+}
diff --git a/kotsadm/pkg/supportbundle/supportbundle.go b/kotsadm/pkg/supportbundle/supportbundle.go
index 64dd50f38..032bc47ea 100644
--- a/kotsadm/pkg/supportbundle/supportbundle.go
+++ b/kotsadm/pkg/supportbundle/supportbundle.go
@@ -373,6 +373,7 @@ func addDefaultTroubleshoot(supportBundle *troubleshootv1beta2.SupportBundle, ap
 
 	supportBundle.Spec.Collectors = append(supportBundle.Spec.Collectors, makeDbCollectors()...)
 	supportBundle.Spec.Collectors = append(supportBundle.Spec.Collectors, makeKotsadmCollectors()...)
+	supportBundle.Spec.Collectors = append(supportBundle.Spec.Collectors, makeGoRoutineCollectors()...)
 	supportBundle.Spec.Collectors = append(supportBundle.Spec.Collectors, makeRookCollectors()...)
 	supportBundle.Spec.Collectors = append(supportBundle.Spec.Collectors, makeKurlCollectors()...)
 	supportBundle.Spec.Collectors = append(supportBundle.Spec.Collectors, makeVeleroCollectors()...)
@@ -441,9 +442,9 @@ func makeKotsadmCollectors() []*troubleshootv1beta2.Collect {
 		"kotsadm-operator",
 		"kurl-proxy-kotsadm",
 	}
-	rookCollectors := []*troubleshootv1beta2.Collect{}
+	kotsadmCollectors := []*troubleshootv1beta2.Collect{}
 	for _, name := range names {
-		rookCollectors = append(rookCollectors, &troubleshootv1beta2.Collect{
+		kotsadmCollectors = append(kotsadmCollectors, &troubleshootv1beta2.Collect{
 			Logs: &troubleshootv1beta2.Logs{
 				CollectorMeta: troubleshootv1beta2.CollectorMeta{
 					CollectorName: name,
@@ -454,7 +455,32 @@ func makeKotsadmCollectors() []*troubleshootv1beta2.Collect {
 			},
 		})
 	}
-	return rookCollectors
+	return kotsadmCollectors
+}
+
+func makeGoRoutineCollectors() []*troubleshootv1beta2.Collect {
+	names := []string{
+		"kotsadm",
+		"kotsadm-operator",
+	}
+	goroutineCollectors := []*troubleshootv1beta2.Collect{}
+	for _, name := range names {
+		goroutineCollectors = append(goroutineCollectors, &troubleshootv1beta2.Collect{
+			Exec: &troubleshootv1beta2.Exec{
+				CollectorMeta: troubleshootv1beta2.CollectorMeta{
+					CollectorName: fmt.Sprintf("%s-goroutines", name),
+				},
+				Name:          "kots/admin-console",
+				Selector:      []string{fmt.Sprintf("app=%s", name)},
+				Namespace:     os.Getenv("POD_NAMESPACE"),
+				ContainerName: name,
+				Command:       []string{"curl"},
+				Args:          []string{"http://localhost:3030/goroutines"},
+				Timeout:       "10s",
+			},
+		})
+	}
+	return goroutineCollectors
 }
 
 func makeRookCollectors() []*troubleshootv1beta2.Collect {
diff --git a/kotsadm/web/env/skaffold.js b/kotsadm/web/env/skaffold.js
index a77a06e7a..4bf7cb694 100644
--- a/kotsadm/web/env/skaffold.js
+++ b/kotsadm/web/env/skaffold.js
@@ -1,7 +1,7 @@
 module.exports = {
   ENVIRONMENT: "development",
   SECURE_ADMIN_CONSOLE: true,
-  API_ENDPOINT: "http://127.0.0.1:30065/api/v1",
+  API_ENDPOINT: "/api/v1",
   KOTSADM_BUILD_VERSION: (function () {
     return String(Date.now());
   }()),
diff --git a/kotsadm/web/kustomize/overlays/dev/deployment.yaml b/kotsadm/web/kustomize/overlays/dev/deployment.yaml
index 63e4945d8..d82076e16 100644
--- a/kotsadm/web/kustomize/overlays/dev/deployment.yaml
+++ b/kotsadm/web/kustomize/overlays/dev/deployment.yaml
@@ -9,9 +9,4 @@ spec:
         - name: kotsadm-web
           ports:
             - name: http
-              containerPort: 30000
-          env:
-            - name: SHIP_CLUSTER_API_SERVER
-              value: "http://127.0.0.1:30065"
-            - name: SHIP_CLUSTER_WEB_URI
-              value: "http://127.0.0.1:30000"
+              containerPort: 30000
\ No newline at end of file
diff --git a/kotsadm/web/kustomize/overlays/dev/kustomization.yaml b/kotsadm/web/kustomize/overlays/dev/kustomization.yaml
index c8134d9b1..3f6a45e81 100644
--- a/kotsadm/web/kustomize/overlays/dev/kustomization.yaml
+++ b/kotsadm/web/kustomize/overlays/dev/kustomization.yaml
@@ -2,7 +2,7 @@ bases:
   - ../../base
 
 resources:
-  - ./nodeport.yaml
+  - ./service.yaml
 
 patchesStrategicMerge:
   - ./deployment.yaml
diff --git a/kotsadm/web/kustomize/overlays/dev/nodeport.yaml b/kotsadm/web/kustomize/overlays/dev/service.yaml
similarity index 74%
rename from kotsadm/web/kustomize/overlays/dev/nodeport.yaml
rename to kotsadm/web/kustomize/overlays/dev/service.yaml
index 5343299cb..13b3bf8a7 100644
--- a/kotsadm/web/kustomize/overlays/dev/nodeport.yaml
+++ b/kotsadm/web/kustomize/overlays/dev/service.yaml
@@ -5,11 +5,10 @@ metadata:
   labels:
     app: kotsadm-web
 spec:
-  type: NodePort
+  type: ClusterIP
   ports:
   - name: http
     port: 30000
     targetPort: http
-    nodePort: 30000
   selector:
-    app: kotsadm-web
+    app: kotsadm-web
\ No newline at end of file
diff --git a/kotsadm/web/nginx.conf b/kotsadm/web/nginx.conf
deleted file mode 100644
index aff38fa73..000000000
--- a/kotsadm/web/nginx.conf
+++ /dev/null
@@ -1,21 +0,0 @@
-server {
-    listen 3000;
-    server_name localhost;
-    root /usr/share/nginx/html;
-
-    # Any route containing a file extension (e.g. /devicesfile.js)
-    location ~ ^.+\..+$ {
-        try_files $uri =404;
-    }
-
-    # health check
-    location /healthz {
-        return 200;
-        access_log off;
-    }
-
-    # Any route that doesn't have a file extension (e.g. /devices)
-    location / {
-        try_files $uri /index.html;
-    }
-}
diff --git a/kotsadm/web/package.json b/kotsadm/web/package.json
index 933fb5c29..6c3c2f15e 100644
--- a/kotsadm/web/package.json
+++ b/kotsadm/web/package.json
@@ -138,6 +138,7 @@
     "resumablejs": "^1.1.0",
     "trim": "^0.0.1",
     "truncate-middle": "^1.0.6",
+    "universal-cookie": "^4.0.4",
     "urlize": "^0.2.1",
     "webpack-bugsnag-plugins": "^1.4.3"
   },
diff --git a/kotsadm/web/src/components/SecureAdminConsole.jsx b/kotsadm/web/src/components/SecureAdminConsole.jsx
index df2f34884..10ed02401 100644
--- a/kotsadm/web/src/components/SecureAdminConsole.jsx
+++ b/kotsadm/web/src/components/SecureAdminConsole.jsx
@@ -1,6 +1,7 @@
 import * as React from "react";
 import Helmet from "react-helmet";
 import { Utilities, dynamicallyResizeText } from "../utilities/utilities";
+import Loader from "./shared/Loader";
 import "../scss/components/Login.scss";
 
 class SecureAdminConsole extends React.Component {
@@ -12,26 +13,36 @@ class SecureAdminConsole extends React.Component {
       passwordErr: false,
       passwordErrMessage: "",
       authLoading: false,
+      loginInfo: null,
     }
 
     this.loginText = React.createRef();
   }
 
-  completeLogin = (data) => {
-    let token = data.token;
-    if (Utilities.localStorageEnabled()) {
-      window.localStorage.setItem("token", token);
-      this.props.onLoginSuccess().then((res) => {
+  completeLogin = async data => {
+    let loggedIn = false;
+
+    try {
+      let token = data.token;
+      if (Utilities.localStorageEnabled()) {
+        window.localStorage.setItem("token", token);
+        loggedIn = true;
+
+        const apps = await this.props.onLoginSuccess();
         this.setState({ authLoading: false });
-        if (res.length > 0) {
-          this.props.history.replace(`/app/${res[0].slug}`);
+        if (apps.length > 0) {
+          this.props.history.replace(`/app/${apps[0].slug}`);
         } else {
           this.props.history.replace("upload-license");
         }
-      });
-    } else {
-      this.props.history.push("/unsupported");
+      } else {
+        this.props.history.push("/unsupported");
+      }
+    } catch(err) {
+      console.log(err);
     }
+
+    return loggedIn;
   }
 
   validatePassword = () => {
@@ -45,7 +56,7 @@ class SecureAdminConsole extends React.Component {
     return true;
   }
 
-  loginToConsole = async () => {
+  loginWithSharedPassword = async () => {
     if (this.validatePassword()) {
       this.setState({ authLoading: true, passwordErr: false, passwordErrMessage: "" });
       fetch(`${window.env.API_ENDPOINT}/login`, {
@@ -84,20 +95,79 @@ class SecureAdminConsole extends React.Component {
     }
   }
 
+  loginWithIdentityProvider = async () => {
+    try {
+      this.setState({ passwordErr: false, passwordErrMessage: "" });
+
+      const res = await fetch(`${window.env.API_ENDPOINT}/oidc/login`, {
+        headers: {
+          "Content-Type": "application/json",
+        },
+        method: "GET",
+      });
+
+      if (res.status >= 400) {
+        const body = await res.json();
+        let msg = body.error;
+        if (!msg) {
+          msg = "There was an error logging in. Please try again.";
+        }
+        this.setState({
+          passwordErr: true,
+          passwordErrMessage: msg,
+        });
+        return;
+      }
+
+      const body = await res.json();
+      window.location = body.authCodeURL
+    } catch(err) {
+      console.log("Login failed:", err);
+      this.setState({
+        passwordErr: true,
+        passwordErrMessage: "There was an error logging in. Please try again",
+      });
+    }
+  }
+
   submitForm = (e) => {
     const enterKey = e.keyCode === 13;
     if (enterKey) {
       e.preventDefault();
       e.stopPropagation();
-      this.loginToConsole();
+      this.loginWithSharedPassword();
     }
   }
 
   resizeLoginFont = () => {
+    if (!this.loginText?.current) {
+      return;
+    }
     const newFontSize = dynamicallyResizeText(this.loginText.current.innerHTML, this.loginText.current.clientWidth, "32px");
     this.loginText.current.style.fontSize = newFontSize;
   }
 
+  getLoginInfo = async () => {
+    try {
+      const response = await fetch(`${window.env.API_ENDPOINT}/login/info`, {
+        headers: {
+          "Content-Type": "application/json",
+        },
+        method: "GET",
+      });
+  
+      if (!response.ok) {
+        console.log(`Unexpected status code ${response.status}`)
+        return;
+      }
+
+      const res = await response.json();
+      this.setState({ loginInfo: res });
+    } catch(err) {
+      console.log(err);
+    }
+  }
+
   componentDidUpdate(lastProps) {
     const { appName } = this.props;
     if (appName && appName !== lastProps.appName) {
@@ -107,6 +177,24 @@ class SecureAdminConsole extends React.Component {
     }
   }
 
+  async componentWillMount() {
+    const token = Utilities.getCookie("token");
+    if (token) {
+      // this is a redirect from identity service login
+      // strip quotes from token (golang adds them when the cookie value has spaces, commas, etc..)
+      const loginData = {
+        token: token.replace(/"/g, ""),
+      };
+      const loggedIn = await this.completeLogin(loginData);
+      if (loggedIn) {
+        Utilities.removeCookie("token");
+      }
+      return;
+    }
+
+    await this.getLoginInfo();
+  }
+
   componentDidMount() {
     window.addEventListener("keydown", this.submitForm);
   }
@@ -126,9 +214,16 @@ class SecureAdminConsole extends React.Component {
       authLoading,
       passwordErr,
       passwordErrMessage,
+      loginInfo,
     } = this.state;
 
-    if (fetchingMetadata) { return null; }
+    if (fetchingMetadata || !loginInfo) {
+      return (
+        <div className="flex-column flex1 alignItems--center justifyContent--center">
+          <Loader size="60" />
+        </div>
+      );
+    }
 
     return (
       <div className="container flex-column flex1 u-overflow--auto Login-wrapper justifyContent--center alignItems--center">
@@ -145,20 +240,26 @@ class SecureAdminConsole extends React.Component {
               }
               <p ref={this.loginText} style={{ fontSize: "32px" }} className="u-marginTop--10 u-paddingTop--5 u-lineHeight--more u-color--tuna u-fontWeight--bold u-width--full u-textAlign--center">Log in{appName && appName !== "" ? ` to ${appName}` : ""}</p>
             </div>
-            <p className="u-marginTop--10 u-marginTop--5 u-fontSize--large u-textAlign--center u-fontWeight--medium u-lineHeight--normal u-color--dustyGray">
-              Enter the password to access the {appName} admin console.
-            </p>
-            <div className="u-marginTop--20 flex-column">
-              {passwordErr && <p className="u-fontSize--normal u-fontWeight--medium u-color--chestnut u-lineHeight--normal u-marginBottom--20">{passwordErrMessage}</p>}
-              <div>
-                <div className="component-wrapper">
-                  <input type="password" className="Input" placeholder="password" autoComplete="current-password" value={password} onChange={(e) => { this.setState({ password: e.target.value }) }}/>
-                </div>
-                <div className="u-marginTop--20 flex">
-                  <button type="submit" className="btn primary" disabled={authLoading} onClick={this.loginToConsole}>{authLoading ? "Logging in" : "Log in"}</button>
+            {loginInfo?.method === "identity-service" ?
+              <button type="submit" className="btn primary u-marginTop--20" onClick={this.loginWithIdentityProvider}>{`Log in with ${loginInfo?.identityConnector}`}</button>
+              : 
+              <div className="flex-auto flex-column justifyContent--center">
+                <p className="u-marginTop--10 u-marginTop--5 u-fontSize--large u-textAlign--center u-fontWeight--medium u-lineHeight--normal u-color--dustyGray">
+                  Enter the password to access the {appName} admin console.
+                </p>
+                <div className="u-marginTop--20 flex-column">
+                  {passwordErr && <p className="u-fontSize--normal u-fontWeight--medium u-color--chestnut u-lineHeight--normal u-marginBottom--20">{passwordErrMessage}</p>}
+                  <div>
+                    <div className="component-wrapper">
+                      <input type="password" className="Input" placeholder="password" autoComplete="current-password" value={password} onChange={(e) => { this.setState({ password: e.target.value }) }}/>
+                    </div>
+                    <div className="u-marginTop--20 flex">
+                      <button type="submit" className="btn primary" disabled={authLoading} onClick={this.loginWithSharedPassword}>{authLoading ? "Logging in" : "Log in"}</button>
+                    </div>
+                  </div>
                 </div>
               </div>
-            </div>
+            }
           </div>
         </div>
       </div>
diff --git a/kotsadm/web/src/components/UploadLicenseFile.jsx b/kotsadm/web/src/components/UploadLicenseFile.jsx
index 0cb43e09c..d0de92c54 100644
--- a/kotsadm/web/src/components/UploadLicenseFile.jsx
+++ b/kotsadm/web/src/components/UploadLicenseFile.jsx
@@ -17,7 +17,8 @@ class UploadLicenseFile extends React.Component {
     licenseFileContent: null,
     fileUploading: false,
     errorMessage: "",
-    viewErrorMessage: false
+    viewErrorMessage: false,
+    licenseExistErrData: {}
   }
 
   clearFile = () => {
@@ -91,9 +92,11 @@ class UploadLicenseFile extends React.Component {
           clearInterval(interval);
 
           if (!data.success) {
+            const licenseExistErr = data?.error?.includes("License already exist");
             this.setState({
               fileUploading: false,
               errorMessage: data.error,
+              licenseExistErrData: licenseExistErr ? data : ""
             });
             return;
           }
@@ -224,6 +227,7 @@ class UploadLicenseFile extends React.Component {
     })
   }
 
+
   render() {
     const {
       appName,
@@ -233,7 +237,7 @@ class UploadLicenseFile extends React.Component {
       isBackupRestore,
       snapshot
     } = this.props;
-    const { licenseFile, fileUploading, errorMessage, viewErrorMessage } = this.state;
+    const { licenseFile, fileUploading, errorMessage, viewErrorMessage, licenseExistErrData } = this.state;
     const hasFile = licenseFile && !isEmpty(licenseFile);
 
     let logoUri;
@@ -342,19 +346,33 @@ class UploadLicenseFile extends React.Component {
             <div className="ExpandedError--wrapper u-marginTop--10 u-marginBottom--10">
               <p className="u-fontSize--small u-fontWeight--bold u-color--tuna u-marginBottom--5">Error description</p>
               <p className="u-fontSize--small u-color--chestnut">{typeof errorMessage === "object" ? "An unknown error orrcured while trying to upload your license. Please try again." : errorMessage}</p>
-              <p className="u-fontSize--small u-fontWeight--bold u-marginTop--15 u-color--tuna">Run this command to generate a support bundle</p>
-              <CodeSnippet
-                language="bash"
-                canCopy={true}
-                onCopyText={<span className="u-color--chateauGreen">Command has been copied to your clipboard</span>}
-              >
-                kubectl support-bundle https://kots.io
+              {!licenseExistErrData ?
+                <div className="flex flex-column">
+                  <p className="u-fontSize--small u-fontWeight--bold u-marginTop--15 u-color--tuna">Run this command to generate a support bundle</p>
+                  <CodeSnippet
+                    language="bash"
+                    canCopy={true}
+                    onCopyText={<span className="u-color--chateauGreen">Command has been copied to your clipboard</span>}
+                  >
+                    kubectl support-bundle https://kots.io
               </CodeSnippet>
+                </div> :
+                <div className="flex flex-column">
+                  <p className="u-fontSize--small u-fontWeight--bold u-marginTop--15 u-color--tuna">Run this command to remove the app</p>
+                  <CodeSnippet
+                    language="bash"
+                    canCopy={true}
+                    onCopyText={<span className="u-color--chateauGreen">Command has been copied to your clipboard</span>}
+                  >
+                    {`kubectl kots remove ${licenseExistErrData?.slug} -n default --force`}
+                  </CodeSnippet>
+                </div>
+              }
             </div>
             <button type="button" className="btn primary u-marginTop--15" onClick={this.toggleViewErrorMessage}>Ok, got it!</button>
           </div>
         </Modal>
-      </div>
+      </div >
     );
   }
 }
diff --git a/kotsadm/web/src/utilities/utilities.js b/kotsadm/web/src/utilities/utilities.js
index 9343d40a7..44efdaa31 100644
--- a/kotsadm/web/src/utilities/utilities.js
+++ b/kotsadm/web/src/utilities/utilities.js
@@ -1,5 +1,6 @@
 import dayjs from "dayjs";
 import relativeTime from "dayjs/plugin/relativeTime";
+import Cookies from 'universal-cookie';
 import utc from "dayjs/plugin/utc";
 import queryString from "query-string";
 import sortBy from "lodash/sortBy";
@@ -387,9 +388,14 @@ export const Utilities = {
     }
   },
 
-  getCookieValue(a) {
-    var b = document.cookie.match("(^|[^;]+)\\s*" + a + "\\s*=\\s*([^;]+)");
-    return b ? b.pop() : "";
+  getCookie(cname) {
+    const cookies = new Cookies();
+    return cookies.get(cname);
+  },
+
+  removeCookie(cname) {
+    const cookies = new Cookies();
+    cookies.remove(cname)
   },
 
   isLoggedIn() {
diff --git a/kotsadm/web/webpack.config.dev.js b/kotsadm/web/webpack.config.dev.js
index 602cafc8e..bbfabea06 100644
--- a/kotsadm/web/webpack.config.dev.js
+++ b/kotsadm/web/webpack.config.dev.js
@@ -47,6 +47,7 @@ module.exports = {
     host: "0.0.0.0",
     hot: true,
     hotOnly: true,
+    disableHostCheck: true,
     historyApiFallback: {
       verbose: true,
       disableDotRule: true
diff --git a/kotsadm/web/yarn.lock b/kotsadm/web/yarn.lock
index 3762c7cd5..805b07a7d 100644
--- a/kotsadm/web/yarn.lock
+++ b/kotsadm/web/yarn.lock
@@ -2119,6 +2119,11 @@
   dependencies:
     "@types/node" "*"
 
+"@types/cookie@^0.3.3":
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803"
+  integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==
+
 "@types/debug@^4.1.4":
   version "4.1.4"
   resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.4.tgz#56eec47706f0fd0b7c694eae2f3172e6b0b769da"
@@ -5403,6 +5408,11 @@ cookie@0.4.0:
   resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
   integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
 
+cookie@^0.4.0:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
+  integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
+
 copy-concurrently@^1.0.0:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0"
@@ -17163,6 +17173,14 @@ unique-string@^1.0.0:
   dependencies:
     crypto-random-string "^1.0.0"
 
+universal-cookie@^4.0.4:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/universal-cookie/-/universal-cookie-4.0.4.tgz#06e8b3625bf9af049569ef97109b4bb226ad798d"
+  integrity sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==
+  dependencies:
+    "@types/cookie" "^0.3.3"
+    cookie "^0.4.0"
+
 universalify@^0.1.0:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
diff --git a/pkg/identity/config.go b/pkg/identity/config.go
new file mode 100644
index 000000000..97b8b3630
--- /dev/null
+++ b/pkg/identity/config.go
@@ -0,0 +1,192 @@
+package identity
+
+import (
+	"context"
+
+	ghodssyaml "github.com/ghodss/yaml"
+	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/pkg/identity/types"
+	kotsadmtypes "github.com/replicatedhq/kots/pkg/kotsadm/types"
+	corev1 "k8s.io/api/core/v1"
+	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+	k8sconfig "sigs.k8s.io/controller-runtime/pkg/client/config"
+)
+
+var (
+	ConfigConfigMapName = "kotsadm-identity-config"
+	ConfigSecretName    = "kotsadm-identity-secret"
+)
+
+func GetConfig(ctx context.Context, namespace string) (*types.Config, error) {
+	cfg, err := k8sconfig.GetConfig()
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get kubernetes config")
+	}
+
+	clientset, err := kubernetes.NewForConfig(cfg)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get client set")
+	}
+
+	config := &types.Config{}
+
+	configMap, err := clientset.CoreV1().ConfigMaps(namespace).Get(ctx, ConfigConfigMapName, metav1.GetOptions{})
+	if err != nil {
+		if kuberneteserrors.IsNotFound(err) {
+			return config, nil
+		}
+		return nil, errors.Wrap(err, "failed to get config map")
+	}
+
+	err = ghodssyaml.Unmarshal([]byte(configMap.Data["config.yaml"]), config)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to unmarshal config")
+	}
+
+	secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, ConfigSecretName, metav1.GetOptions{})
+	if err != nil {
+		if kuberneteserrors.IsNotFound(err) {
+			return config, nil
+		}
+		return nil, errors.Wrap(err, "failed to get secret")
+	}
+
+	err = ghodssyaml.Unmarshal(secret.Data["dexConnectors"], &config.DexConnectors)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to unmarshal dex connectors")
+	}
+
+	return config, err
+}
+
+func SetConfig(ctx context.Context, namespace string, config types.Config) error {
+	cfg, err := k8sconfig.GetConfig()
+	if err != nil {
+		return errors.Wrap(err, "failed to get kubernetes config")
+	}
+
+	clientset, err := kubernetes.NewForConfig(cfg)
+	if err != nil {
+		return errors.Wrap(err, "failed to get client set")
+	}
+
+	err = ensureConfigSecret(ctx, clientset, namespace, config)
+	if err != nil {
+		return errors.Wrap(err, "failed to ensure secret")
+	}
+
+	err = ensureConfigConfigMap(ctx, clientset, namespace, config)
+	if err != nil {
+		return errors.Wrap(err, "failed to ensure config map")
+	}
+
+	return nil
+}
+
+func ensureConfigConfigMap(ctx context.Context, clientset kubernetes.Interface, namespace string, config types.Config) error {
+	configMap, err := identityConfigMapResource(config)
+	if err != nil {
+		return err
+	}
+
+	existingConfigMap, err := clientset.CoreV1().ConfigMaps(namespace).Get(ctx, ConfigConfigMapName, metav1.GetOptions{})
+	if err != nil {
+		if !kuberneteserrors.IsNotFound(err) {
+			return errors.Wrap(err, "failed to get config map")
+		}
+
+		_, err = clientset.CoreV1().ConfigMaps(namespace).Create(ctx, configMap, metav1.CreateOptions{})
+		if err != nil {
+			return errors.Wrap(err, "failed to create config map")
+		}
+
+		return nil
+	}
+
+	existingConfigMap.Data = configMap.Data
+
+	_, err = clientset.CoreV1().ConfigMaps(namespace).Update(ctx, existingConfigMap, metav1.UpdateOptions{})
+	if err != nil {
+		return errors.Wrap(err, "failed to update config map")
+	}
+
+	return nil
+}
+
+func identityConfigMapResource(config types.Config) (*corev1.ConfigMap, error) {
+	config.DexConnectors = nil // stored in a secret
+
+	data, err := ghodssyaml.Marshal(config)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to marshal config")
+	}
+	return &corev1.ConfigMap{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "v1",
+			Kind:       "ConfigMap",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name: ConfigConfigMapName,
+			Labels: kotsadmtypes.GetKotsadmLabels(map[string]string{
+				KotsIdentityLabelKey: KotsIdentityLabelValue,
+			}),
+		},
+		Data: map[string]string{
+			"config.yaml": string(data),
+		},
+	}, nil
+}
+
+func ensureConfigSecret(ctx context.Context, clientset kubernetes.Interface, namespace string, config types.Config) error {
+	secret, err := identitySecretResource(config)
+	if err != nil {
+		return err
+	}
+
+	existingSecret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, ConfigSecretName, metav1.GetOptions{})
+	if err != nil {
+		if !kuberneteserrors.IsNotFound(err) {
+			return errors.Wrap(err, "failed to get secret")
+		}
+
+		_, err = clientset.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{})
+		if err != nil {
+			return errors.Wrap(err, "failed to create secret")
+		}
+
+		return nil
+	}
+
+	existingSecret.Data = secret.Data
+
+	_, err = clientset.CoreV1().Secrets(namespace).Update(ctx, existingSecret, metav1.UpdateOptions{})
+	if err != nil {
+		return errors.Wrap(err, "failed to update secret")
+	}
+
+	return nil
+}
+
+func identitySecretResource(config types.Config) (*corev1.Secret, error) {
+	data, err := ghodssyaml.Marshal(config.DexConnectors)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to marshal secret")
+	}
+	return &corev1.Secret{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "v1",
+			Kind:       "Secret",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name: ConfigSecretName,
+			Labels: kotsadmtypes.GetKotsadmLabels(map[string]string{
+				KotsIdentityLabelKey: KotsIdentityLabelValue,
+			}),
+		},
+		Data: map[string][]byte{
+			"dexConnectors": data,
+		},
+	}, nil
+}
diff --git a/pkg/identity/crds.go b/pkg/identity/crds.go
new file mode 100644
index 000000000..5c659dac7
--- /dev/null
+++ b/pkg/identity/crds.go
@@ -0,0 +1,169 @@
+package identity
+
+import (
+	extensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+var crdMeta = metav1.TypeMeta{
+	APIVersion: "apiextensions.k8s.io/v1beta1",
+	Kind:       "CustomResourceDefinition",
+}
+
+const dexApiGroup = "dex.coreos.com"
+
+var DexCustomResourceDefinitions = []extensionsv1beta1.CustomResourceDefinition{
+	{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "authcodes.dex.coreos.com",
+		},
+		TypeMeta: crdMeta,
+		Spec: extensionsv1beta1.CustomResourceDefinitionSpec{
+			Group:   dexApiGroup,
+			Version: "v1",
+			Names: extensionsv1beta1.CustomResourceDefinitionNames{
+				Plural:   "authcodes",
+				Singular: "authcode",
+				Kind:     "AuthCode",
+			},
+		},
+	},
+	{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "authrequests.dex.coreos.com",
+		},
+		TypeMeta: crdMeta,
+		Spec: extensionsv1beta1.CustomResourceDefinitionSpec{
+			Group:   dexApiGroup,
+			Version: "v1",
+			Names: extensionsv1beta1.CustomResourceDefinitionNames{
+				Plural:   "authrequests",
+				Singular: "authrequest",
+				Kind:     "AuthRequest",
+			},
+		},
+	},
+	{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "oauth2clients.dex.coreos.com",
+		},
+		TypeMeta: crdMeta,
+		Spec: extensionsv1beta1.CustomResourceDefinitionSpec{
+			Group:   dexApiGroup,
+			Version: "v1",
+			Names: extensionsv1beta1.CustomResourceDefinitionNames{
+				Plural:   "oauth2clients",
+				Singular: "oauth2client",
+				Kind:     "OAuth2Client",
+			},
+		},
+	},
+	{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "signingkeies.dex.coreos.com",
+		},
+		TypeMeta: crdMeta,
+		Spec: extensionsv1beta1.CustomResourceDefinitionSpec{
+			Group:   dexApiGroup,
+			Version: "v1",
+			Names: extensionsv1beta1.CustomResourceDefinitionNames{
+				// `signingkeies` is an artifact from the old TPR pluralization.
+				// Users don't directly interact with this value, hence leaving it
+				// as is.
+				Plural:   "signingkeies",
+				Singular: "signingkey",
+				Kind:     "SigningKey",
+			},
+		},
+	},
+	{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "refreshtokens.dex.coreos.com",
+		},
+		TypeMeta: crdMeta,
+		Spec: extensionsv1beta1.CustomResourceDefinitionSpec{
+			Group:   dexApiGroup,
+			Version: "v1",
+			Names: extensionsv1beta1.CustomResourceDefinitionNames{
+				Plural:   "refreshtokens",
+				Singular: "refreshtoken",
+				Kind:     "RefreshToken",
+			},
+		},
+	},
+	{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "passwords.dex.coreos.com",
+		},
+		TypeMeta: crdMeta,
+		Spec: extensionsv1beta1.CustomResourceDefinitionSpec{
+			Group:   dexApiGroup,
+			Version: "v1",
+			Names: extensionsv1beta1.CustomResourceDefinitionNames{
+				Plural:   "passwords",
+				Singular: "password",
+				Kind:     "Password",
+			},
+		},
+	},
+	{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "offlinesessionses.dex.coreos.com",
+		},
+		TypeMeta: crdMeta,
+		Spec: extensionsv1beta1.CustomResourceDefinitionSpec{
+			Group:   dexApiGroup,
+			Version: "v1",
+			Names: extensionsv1beta1.CustomResourceDefinitionNames{
+				Plural:   "offlinesessionses",
+				Singular: "offlinesessions",
+				Kind:     "OfflineSessions",
+			},
+		},
+	},
+	{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "connectors.dex.coreos.com",
+		},
+		TypeMeta: crdMeta,
+		Spec: extensionsv1beta1.CustomResourceDefinitionSpec{
+			Group:   dexApiGroup,
+			Version: "v1",
+			Names: extensionsv1beta1.CustomResourceDefinitionNames{
+				Plural:   "connectors",
+				Singular: "connector",
+				Kind:     "Connector",
+			},
+		},
+	},
+	{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "devicerequests.dex.coreos.com",
+		},
+		TypeMeta: crdMeta,
+		Spec: extensionsv1beta1.CustomResourceDefinitionSpec{
+			Group:   dexApiGroup,
+			Version: "v1",
+			Names: extensionsv1beta1.CustomResourceDefinitionNames{
+				Plural:   "devicerequests",
+				Singular: "devicerequest",
+				Kind:     "DeviceRequest",
+			},
+		},
+	},
+	{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "devicetokens.dex.coreos.com",
+		},
+		TypeMeta: crdMeta,
+		Spec: extensionsv1beta1.CustomResourceDefinitionSpec{
+			Group:   dexApiGroup,
+			Version: "v1",
+			Names: extensionsv1beta1.CustomResourceDefinitionNames{
+				Plural:   "devicetokens",
+				Singular: "devicetoken",
+				Kind:     "DeviceToken",
+			},
+		},
+	},
+}
diff --git a/pkg/identity/deploy.go b/pkg/identity/deploy.go
new file mode 100644
index 000000000..9e97ae852
--- /dev/null
+++ b/pkg/identity/deploy.go
@@ -0,0 +1,634 @@
+package identity
+
+import (
+	"bytes"
+	"context"
+	"crypto/md5"
+	"fmt"
+	"text/template"
+
+	dexstorage "github.com/dexidp/dex/storage"
+	dexk8sstorage "github.com/dexidp/dex/storage/kubernetes"
+	"github.com/ghodss/yaml"
+	"github.com/pkg/errors"
+	identitytypes "github.com/replicatedhq/kots/pkg/identity/types"
+	dextypes "github.com/replicatedhq/kots/pkg/identity/types/dex"
+	"github.com/replicatedhq/kots/pkg/ingress"
+	ingresstypes "github.com/replicatedhq/kots/pkg/ingress/types"
+	kotsadmtypes "github.com/replicatedhq/kots/pkg/kotsadm/types"
+	"github.com/replicatedhq/kots/pkg/logger"
+	"github.com/segmentio/ksuid"
+	appsv1 "k8s.io/api/apps/v1"
+	corev1 "k8s.io/api/core/v1"
+	extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
+	rbacv1 "k8s.io/api/rbac/v1"
+	apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
+	apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
+	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/api/resource"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/intstr"
+	"k8s.io/client-go/kubernetes"
+	"sigs.k8s.io/controller-runtime/pkg/client/config"
+)
+
+const (
+	DexDeploymentName, DexServiceName, DexIngressName      = "kotsadm-dex", "kotsadm-dex", "kotsadm-dex"
+	DexServiceAccountName, DexRoleName, DexRoleBindingName = "kotsadm-dex", "kotsadm-dex", "kotsadm-dex"
+	DexSecretName                                          = "kotsadm-dex"
+)
+
+var (
+	DexAdditionalLabels = map[string]string{
+		KotsIdentityLabelKey: KotsIdentityLabelValue,
+	}
+)
+
+func Deploy(ctx context.Context, logger *logger.Logger, clientset kubernetes.Interface, namespace string, identityConfig identitytypes.Config, ingressConfig ingresstypes.Config) error {
+	if err := DeployCRDs(ctx, logger, clientset); err != nil {
+		// Dex will deploy this if it has permissions
+		logger.Error(errors.Wrap(err, "failed to deploy crds"))
+	}
+	if err := DeployServiceAccount(ctx, logger, clientset, namespace); err != nil {
+		return errors.Wrap(err, "failed to deploy service account")
+	}
+	marshalledDexConfig, err := getDexConfig(ctx, clientset, namespace, identityConfig, ingressConfig)
+	if err != nil {
+		return errors.Wrap(err, "failed to marshal dex config")
+	}
+	if err := ensureSecret(ctx, clientset, namespace, marshalledDexConfig); err != nil {
+		return errors.Wrap(err, "failed to ensure secret")
+	}
+	if err := ensureDeployment(ctx, clientset, namespace, marshalledDexConfig); err != nil {
+		return errors.Wrap(err, "failed to ensure deployment")
+	}
+	if err := ensureService(ctx, clientset, namespace, identityConfig.IngressConfig.NodePort); err != nil {
+		return errors.Wrap(err, "failed to ensure service")
+	}
+	if err := ensureIngress(ctx, clientset, namespace, identityConfig.IngressConfig); err != nil {
+		return errors.Wrap(err, "failed to ensure ingress")
+	}
+	return nil
+}
+
+func DeployServiceAccount(ctx context.Context, logger *logger.Logger, clientset kubernetes.Interface, namespace string) error {
+	if err := ensureServiceAccount(ctx, clientset, namespace); err != nil {
+		return err
+	}
+	if err := ensureRole(ctx, clientset, namespace); err != nil {
+		return err
+	}
+	if err := ensureRoleBinding(ctx, clientset, namespace); err != nil {
+		return err
+	}
+	return nil
+}
+
+func DeployCRDs(ctx context.Context, logger *logger.Logger, clientset kubernetes.Interface) error {
+	cfg, err := config.GetConfig()
+	if err != nil {
+		return errors.Wrap(err, "failed to get cluster config")
+	}
+
+	apiextensionsclientset, err := apiextensionsclientset.NewForConfig(cfg)
+	if err != nil {
+		return errors.Wrap(err, "failed to create apiextensions clientset")
+	}
+
+	for _, crd := range DexCustomResourceDefinitions {
+		if err := ensureCRD(ctx, apiextensionsclientset, crd); err != nil {
+			return err
+		}
+	}
+
+	// Dex will automatically wait for crds
+	return nil
+}
+
+func ensureSecret(ctx context.Context, clientset kubernetes.Interface, namespace string, marshalledConfig []byte) error {
+	secret, err := secretResource(DexSecretName, marshalledConfig)
+	if err != nil {
+		return errors.Wrap(err, "failed to get secret resource")
+	}
+
+	existingSecret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, DexSecretName, metav1.GetOptions{})
+	if err != nil {
+		if !kuberneteserrors.IsNotFound(err) {
+			return errors.Wrap(err, "failed to get existing secret")
+		}
+
+		_, err = clientset.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{})
+		if err != nil {
+			return errors.Wrap(err, "failed to create secret")
+		}
+
+		return nil
+	}
+
+	existingSecret = updateSecret(existingSecret, secret)
+
+	_, err = clientset.CoreV1().Secrets(namespace).Update(ctx, existingSecret, metav1.UpdateOptions{})
+	if err != nil {
+		return errors.Wrap(err, "failed to update secret")
+	}
+
+	return nil
+}
+
+func getExistingDexConfig(ctx context.Context, clientset kubernetes.Interface, namespace string) (*dextypes.Config, error) {
+	dexConfig := dextypes.Config{}
+
+	existingSecret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, DexSecretName, metav1.GetOptions{})
+	if err != nil {
+		if !kuberneteserrors.IsNotFound(err) {
+			return nil, errors.Wrap(err, "failed to get existing secret")
+		}
+		return &dexConfig, nil
+	}
+
+	err = yaml.Unmarshal(existingSecret.Data["dexConfig.yaml"], &dexConfig)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to unmarshal dexConfig.yaml")
+	}
+
+	return &dexConfig, nil
+}
+
+func getDexConfig(ctx context.Context, clientset kubernetes.Interface, namespace string, identityConfig identitytypes.Config, ingressConfig ingresstypes.Config) ([]byte, error) {
+	existingConfig, err := getExistingDexConfig(ctx, clientset, namespace)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get existing dex config")
+	}
+
+	staticClients := existingConfig.StaticClients
+	kotsadmClient := dexstorage.Client{
+		ID:     "kotsadm",
+		Name:   "kotsadm",
+		Secret: ksuid.New().String(),
+		RedirectURIs: []string{
+			fmt.Sprintf("%s/api/v1/oidc/login/callback", ingress.GetAddress(ingressConfig)),
+		},
+	}
+	foundKotsClient := false
+	for i := range staticClients {
+		if staticClients[i].ID == "kotsadm" {
+			staticClients[i].RedirectURIs = kotsadmClient.RedirectURIs
+			foundKotsClient = true
+		}
+	}
+	if !foundKotsClient {
+		staticClients = append(staticClients, kotsadmClient)
+	}
+
+	config := dextypes.Config{
+		Logger: dextypes.Logger{
+			Level:  "debug",
+			Format: "text",
+		},
+		Issuer: DexIssuerURL(identityConfig.IngressConfig),
+		Storage: dextypes.Storage{
+			Type: "kubernetes",
+			Config: dexk8sstorage.Config{
+				InCluster: true,
+			},
+		},
+		Web: dextypes.Web{
+			HTTP: "0.0.0.0:5556",
+		},
+		OAuth2: dextypes.OAuth2{
+			SkipApprovalScreen: true,
+		},
+		StaticClients:    staticClients,
+		EnablePasswordDB: false,
+	}
+
+	if len(identityConfig.DexConnectors) > 0 {
+		config.StaticConnectors = identityConfig.DexConnectors
+	}
+
+	if err := config.Validate(); err != nil {
+		return nil, errors.Wrap(err, "failed to validate dex config")
+	}
+
+	marshalledConfig, err := yaml.Marshal(config)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to marshal dex config")
+	}
+
+	buf := bytes.NewBuffer(nil)
+	t, err := template.New("kotsadm-dex").Funcs(template.FuncMap{
+		"OIDCIdentityCallbackURL": func() string { return DexCallbackURL(identityConfig.IngressConfig) },
+	}).Parse(string(marshalledConfig))
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to parse dex config for templating")
+	}
+	if err := t.Execute(buf, nil); err != nil {
+		return nil, errors.Wrap(err, "failed to execute template")
+	}
+
+	return buf.Bytes(), nil
+}
+
+func secretResource(secretName string, marshalledConfig []byte) (*corev1.Secret, error) {
+	return &corev1.Secret{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "v1",
+			Kind:       "Secret",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   secretName,
+			Labels: kotsadmtypes.GetKotsadmLabels(DexAdditionalLabels),
+		},
+		Data: map[string][]byte{
+			"dexConfig.yaml": marshalledConfig,
+		},
+	}, nil
+}
+
+func updateSecret(existingSecret, desiredSecret *corev1.Secret) *corev1.Secret {
+	existingSecret.Data = desiredSecret.Data
+	return existingSecret
+}
+
+func ensureDeployment(ctx context.Context, clientset kubernetes.Interface, namespace string, marshalledDexConfig []byte) error {
+	configChecksum := fmt.Sprintf("%x", md5.Sum(marshalledDexConfig))
+
+	deployment := deploymentResource(DexDeploymentName, DexServiceAccountName, configChecksum)
+
+	existingDeployment, err := clientset.AppsV1().Deployments(namespace).Get(ctx, DexDeploymentName, metav1.GetOptions{})
+	if err != nil {
+		if !kuberneteserrors.IsNotFound(err) {
+			return errors.Wrap(err, "failed to get existing deployment")
+		}
+
+		_, err = clientset.AppsV1().Deployments(namespace).Create(ctx, deployment, metav1.CreateOptions{})
+		if err != nil {
+			return errors.Wrap(err, "failed to create deployment")
+		}
+
+		return nil
+	}
+
+	existingDeployment = updateDeployment(existingDeployment, deployment)
+
+	_, err = clientset.AppsV1().Deployments(namespace).Update(ctx, existingDeployment, metav1.UpdateOptions{})
+	if err != nil {
+		return errors.Wrap(err, "failed to update deployment")
+	}
+
+	return nil
+}
+
+var (
+	dexCPUResource    = resource.MustParse("100m")
+	dexMemoryResource = resource.MustParse("50Mi")
+)
+
+func deploymentResource(deploymentName, serviceAccountName string, configChecksum string) *appsv1.Deployment {
+	replicas := int32(2)
+	volume := configSecretVolume()
+	return &appsv1.Deployment{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "apps/v1",
+			Kind:       "Deployment",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   deploymentName,
+			Labels: kotsadmtypes.GetKotsadmLabels(DexAdditionalLabels),
+		},
+		Spec: appsv1.DeploymentSpec{
+			Replicas: &replicas,
+			Selector: &metav1.LabelSelector{
+				MatchLabels: map[string]string{
+					"app": "dex",
+				},
+			},
+			Template: corev1.PodTemplateSpec{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels: map[string]string{
+						"app": "dex",
+					},
+					Annotations: map[string]string{
+						"kots.io/dex-secret-checksum": configChecksum,
+					},
+				},
+				Spec: corev1.PodSpec{
+					ServiceAccountName: serviceAccountName,
+					Containers: []corev1.Container{
+						{
+							// TODO: airgap registry
+							// fmt.Sprintf("%s/kotsadm:%s", kotsadmRegistry(deployOptions.KotsadmOptions), kotsadmTag(deployOptions.KotsadmOptions)),
+							Image:           "quay.io/dexidp/dex:v2.26.0",
+							ImagePullPolicy: corev1.PullIfNotPresent,
+							Name:            "dex",
+							Command:         []string{"/usr/local/bin/dex", "serve", "/etc/dex/cfg/dexConfig.yaml"},
+							Ports: []corev1.ContainerPort{
+								{Name: "http", ContainerPort: 5556},
+							},
+							VolumeMounts: []corev1.VolumeMount{
+								{Name: volume.Name, MountPath: "/etc/dex/cfg"},
+							},
+							Resources: corev1.ResourceRequirements{
+								Limits: corev1.ResourceList{
+									"cpu":    dexCPUResource,
+									"memory": dexMemoryResource,
+								},
+								Requests: corev1.ResourceList{
+									"cpu":    dexCPUResource,
+									"memory": dexMemoryResource,
+								},
+							},
+						},
+					},
+					Volumes: []corev1.Volume{
+						volume,
+					},
+				},
+			},
+		},
+	}
+}
+
+func configSecretVolume() corev1.Volume {
+	return corev1.Volume{
+		Name: "config",
+		VolumeSource: corev1.VolumeSource{
+			Secret: &corev1.SecretVolumeSource{
+				SecretName: DexSecretName,
+			},
+		},
+	}
+}
+
+func updateDeployment(existingDeployment, desiredDeployment *appsv1.Deployment) *appsv1.Deployment {
+	if len(existingDeployment.Spec.Template.Spec.Containers) == 0 {
+		// wtf
+		return desiredDeployment
+	}
+
+	if existingDeployment.Spec.Template.Annotations == nil {
+		existingDeployment.Spec.Template.Annotations = map[string]string{}
+	}
+	existingDeployment.Spec.Template.Annotations["kots.io/dex-secret-checksum"] = desiredDeployment.Spec.Template.Annotations["kots.io/dex-secret-checksum"]
+
+	existingDeployment.Spec.Template.Spec.Containers[0].Image = desiredDeployment.Spec.Template.Spec.Containers[0].Image
+
+	existingDeployment = updateDeploymentConfigSecretVolume(existingDeployment, desiredDeployment)
+
+	return existingDeployment
+}
+
+func updateDeploymentConfigSecretVolume(existingDeployment *appsv1.Deployment, desiredDeployment *appsv1.Deployment) *appsv1.Deployment {
+	if len(existingDeployment.Spec.Template.Spec.Containers) == 0 {
+		return desiredDeployment
+	}
+
+	newConfigSecretVolume := configSecretVolume()
+	newConfigSecretVolumeMount := corev1.VolumeMount{Name: newConfigSecretVolume.Name, MountPath: "/etc/dex/cfg"}
+
+	var existingSecretVolumeName string
+	for i, volumeMount := range existingDeployment.Spec.Template.Spec.Containers[0].VolumeMounts {
+		if volumeMount.MountPath == "/etc/dex/cfg" {
+			existingSecretVolumeName = volumeMount.Name
+			existingDeployment.Spec.Template.Spec.Containers[0].VolumeMounts[i] = newConfigSecretVolumeMount
+			break
+		}
+	}
+	if existingSecretVolumeName != "" {
+		for i, volume := range existingDeployment.Spec.Template.Spec.Volumes {
+			if volume.Name == existingSecretVolumeName {
+				existingDeployment.Spec.Template.Spec.Volumes[i] = newConfigSecretVolume
+			}
+		}
+		return existingDeployment
+	}
+
+	existingDeployment.Spec.Template.Spec.Containers[0].VolumeMounts =
+		append(existingDeployment.Spec.Template.Spec.Containers[0].VolumeMounts, newConfigSecretVolumeMount)
+	existingDeployment.Spec.Template.Spec.Volumes =
+		append(existingDeployment.Spec.Template.Spec.Volumes, newConfigSecretVolume)
+
+	return existingDeployment
+}
+
+func ensureService(ctx context.Context, clientset kubernetes.Interface, namespace string, nodePortConfig *ingresstypes.NodePortConfig) error {
+	service := serviceResource(DexServiceName, nodePortConfig)
+
+	existingService, err := clientset.CoreV1().Services(namespace).Get(ctx, DexServiceName, metav1.GetOptions{})
+	if err != nil {
+		if !kuberneteserrors.IsNotFound(err) {
+			return errors.Wrap(err, "failed to get existing service")
+		}
+
+		_, err = clientset.CoreV1().Services(namespace).Create(ctx, service, metav1.CreateOptions{})
+		if err != nil {
+			return errors.Wrap(err, "failed to create service")
+		}
+
+		return nil
+	}
+
+	existingService = updateService(existingService, service)
+
+	_, err = clientset.CoreV1().Services(namespace).Update(ctx, existingService, metav1.UpdateOptions{})
+	if err != nil {
+		return errors.Wrap(err, "failed to update service")
+	}
+
+	return nil
+}
+
+func serviceResource(serviceName string, nodePortConfig *ingresstypes.NodePortConfig) *corev1.Service {
+	serviceType := corev1.ServiceTypeClusterIP
+	port := corev1.ServicePort{
+		Name:       "http",
+		Port:       5556,
+		TargetPort: intstr.IntOrString{Type: intstr.Int, IntVal: 5556},
+	}
+	if nodePortConfig != nil && nodePortConfig.Port != 0 {
+		port.NodePort = int32(nodePortConfig.Port)
+		serviceType = corev1.ServiceTypeNodePort
+	}
+	return &corev1.Service{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "v1",
+			Kind:       "Service",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   serviceName,
+			Labels: kotsadmtypes.GetKotsadmLabels(DexAdditionalLabels),
+		},
+		Spec: corev1.ServiceSpec{
+			Type: serviceType,
+			Selector: map[string]string{
+				"app": "dex",
+			},
+			Ports: []corev1.ServicePort{
+				port,
+			},
+		},
+	}
+}
+
+func updateService(existingService, desiredService *corev1.Service) *corev1.Service {
+	existingService.Spec.Ports = desiredService.Spec.Ports
+
+	return existingService
+}
+
+func ensureServiceAccount(ctx context.Context, clientset kubernetes.Interface, namespace string) error {
+	_, err := clientset.CoreV1().ServiceAccounts(namespace).Get(ctx, DexServiceAccountName, metav1.GetOptions{})
+	if err != nil {
+		if !kuberneteserrors.IsNotFound(err) {
+			return errors.Wrap(err, "failed to get existing service account")
+		}
+
+		_, err = clientset.CoreV1().ServiceAccounts(namespace).Create(ctx, serviceAccountResource(DexServiceAccountName), metav1.CreateOptions{})
+		if err != nil {
+			return errors.Wrap(err, "failed to create service account")
+		}
+
+		return nil
+	}
+
+	// no patch necessary
+
+	return nil
+}
+
+func serviceAccountResource(serviceAccountName string) *corev1.ServiceAccount {
+	return &corev1.ServiceAccount{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "v1",
+			Kind:       "ServiceAccount",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   serviceAccountName,
+			Labels: kotsadmtypes.GetKotsadmLabels(DexAdditionalLabels),
+		},
+	}
+}
+
+func ensureRole(ctx context.Context, clientset kubernetes.Interface, namespace string) error {
+	_, err := clientset.RbacV1().Roles(namespace).Get(ctx, DexRoleName, metav1.GetOptions{})
+	if err != nil {
+		if !kuberneteserrors.IsNotFound(err) {
+			return errors.Wrap(err, "failed to get existing role")
+		}
+
+		_, err = clientset.RbacV1().Roles(namespace).Create(ctx, roleResource(DexRoleName), metav1.CreateOptions{})
+		if err != nil {
+			return errors.Wrap(err, "failed to create role")
+		}
+
+		return nil
+	}
+
+	// no patch necessary
+
+	return nil
+}
+
+func roleResource(roleName string) *rbacv1.Role {
+	return &rbacv1.Role{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "rbac.authorization.k8s.io/v1",
+			Kind:       "Role",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   roleName,
+			Labels: kotsadmtypes.GetKotsadmLabels(DexAdditionalLabels),
+		},
+		Rules: []rbacv1.PolicyRule{
+			{
+				APIGroups: []string{"dex.coreos.com"}, // API group created by dex
+				Resources: []string{"*"},
+				Verbs:     []string{"*"},
+			},
+			{
+				// This will no longer be needed if kots deploys dex crds
+				APIGroups: []string{"apiextensions.k8s.io"},
+				Resources: []string{"customresourcedefinitions"},
+				Verbs:     []string{"create"}, // To manage its own resources, dex must be able to create customresourcedefinitions
+			},
+		},
+	}
+}
+
+func ensureRoleBinding(ctx context.Context, clientset kubernetes.Interface, namespace string) error {
+	_, err := clientset.RbacV1().RoleBindings(namespace).Get(ctx, DexRoleBindingName, metav1.GetOptions{})
+	if err != nil {
+		if !kuberneteserrors.IsNotFound(err) {
+			return errors.Wrap(err, "failed to get existing role binding")
+		}
+
+		roleBinding := roleBindingResource(DexRoleBindingName, DexRoleName, DexServiceAccountName, namespace)
+		_, err = clientset.RbacV1().RoleBindings(namespace).Create(ctx, roleBinding, metav1.CreateOptions{})
+		if err != nil {
+			return errors.Wrap(err, "failed to create role binding")
+		}
+
+		return nil
+	}
+
+	// no patch necessary
+
+	return nil
+}
+
+func roleBindingResource(roleBindingName, roleName, serviceAccountName, serviceAccountNamespace string) *rbacv1.RoleBinding {
+	return &rbacv1.RoleBinding{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "rbac.authorization.k8s.io/v1",
+			Kind:       "RoleBinding",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   roleBindingName,
+			Labels: kotsadmtypes.GetKotsadmLabels(DexAdditionalLabels),
+		},
+		RoleRef: rbacv1.RoleRef{
+			APIGroup: "rbac.authorization.k8s.io",
+			Kind:     "Role",
+			Name:     roleName,
+		},
+		Subjects: []rbacv1.Subject{
+			{
+				Kind:      "ServiceAccount",
+				Name:      serviceAccountName,
+				Namespace: serviceAccountNamespace,
+			},
+		},
+	}
+}
+
+func ensureCRD(ctx context.Context, clientset apiextensionsclientset.Interface, crd apiextensionsv1beta1.CustomResourceDefinition) error {
+	_, err := clientset.ApiextensionsV1beta1().CustomResourceDefinitions().Get(ctx, crd.Name, metav1.GetOptions{})
+	if err != nil {
+		if !kuberneteserrors.IsNotFound(err) {
+			return errors.Wrapf(err, "failed to get existing crd %s", crd.Spec.Names.Plural)
+		}
+
+		_, err = clientset.ApiextensionsV1beta1().CustomResourceDefinitions().Create(ctx, &crd, metav1.CreateOptions{})
+		if err != nil {
+			return errors.Wrapf(err, "failed to create crd %s", crd.Spec.Names.Plural)
+		}
+
+		return nil
+	}
+
+	// no patch necessary
+
+	return nil
+}
+
+func ensureIngress(ctx context.Context, clientset kubernetes.Interface, namespace string, ingressConfig ingresstypes.Config) error {
+	if ingressConfig.Ingress == nil {
+		return deleteIngress(ctx, clientset, namespace)
+	}
+	dexIngress := ingressResource(namespace, ingressConfig)
+	return ingress.EnsureIngress(ctx, clientset, namespace, dexIngress)
+}
+
+func ingressResource(namespace string, ingressConfig ingresstypes.Config) *extensionsv1beta1.Ingress {
+	return ingress.IngressFromConfig(*ingressConfig.Ingress, DexIngressName, DexServiceName, 5556, DexAdditionalLabels)
+}
diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go
new file mode 100644
index 000000000..b67499315
--- /dev/null
+++ b/pkg/identity/identity.go
@@ -0,0 +1,21 @@
+package identity
+
+import (
+	"fmt"
+
+	"github.com/replicatedhq/kots/pkg/ingress"
+	ingresstypes "github.com/replicatedhq/kots/pkg/ingress/types"
+)
+
+var (
+	KotsIdentityLabelKey   = "kots.io/identity"
+	KotsIdentityLabelValue = "true"
+)
+
+func DexIssuerURL(ingressConfig ingresstypes.Config) string {
+	return fmt.Sprintf("%s/dex", ingress.GetAddress(ingressConfig))
+}
+
+func DexCallbackURL(ingressConfig ingresstypes.Config) string {
+	return fmt.Sprintf("%s/callback", DexIssuerURL(ingressConfig))
+}
diff --git a/pkg/identity/rbac.go b/pkg/identity/rbac.go
new file mode 100644
index 000000000..44ad87a27
--- /dev/null
+++ b/pkg/identity/rbac.go
@@ -0,0 +1,17 @@
+package identity
+
+import (
+	"github.com/replicatedhq/kots/pkg/rbac"
+	rbactypes "github.com/replicatedhq/kots/pkg/rbac/types"
+)
+
+func RestrictedGroupsToRBACGroups(restrictedGroups []string) []rbactypes.Group {
+	groups := []rbactypes.Group{}
+	for _, restrictedGroup := range restrictedGroups {
+		groups = append(groups, rbactypes.Group{
+			ID:      restrictedGroup,
+			RoleIDs: []string{rbac.ClusterAdminRoleID},
+		})
+	}
+	return groups
+}
diff --git a/pkg/identity/types/dex/dex.go b/pkg/identity/types/dex/dex.go
new file mode 100644
index 000000000..574dd07be
--- /dev/null
+++ b/pkg/identity/types/dex/dex.go
@@ -0,0 +1,237 @@
+package types
+
+/*
+This file has been copied from dex repo and MODIFIED because it was part
+of the "main" package and could not be imported.
+link: https://github.com/dexidp/dex/blob/master/cmd/dex/config.go
+*/
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"os"
+	"strings"
+
+	"golang.org/x/crypto/bcrypt"
+
+	"github.com/dexidp/dex/server"
+	"github.com/dexidp/dex/storage"
+	k8sstorage "github.com/dexidp/dex/storage/kubernetes"
+)
+
+// Config is the config format for the main application.
+type Config struct {
+	Issuer    string    `json:"issuer"`
+	Storage   Storage   `json:"storage"`
+	Web       Web       `json:"web"`
+	Telemetry Telemetry `json:"telemetry"`
+	OAuth2    OAuth2    `json:"oauth2"`
+	GRPC      GRPC      `json:"grpc"`
+	Expiry    Expiry    `json:"expiry"`
+	Logger    Logger    `json:"logger"`
+
+	Frontend server.WebConfig `json:"frontend"`
+
+	// StaticConnectors are user defined connectors specified in the ConfigMap
+	// Write operations, like updating a connector, will fail.
+	StaticConnectors []Connector `json:"connectors"`
+
+	// StaticClients cause the server to use this list of clients rather than
+	// querying the storage. Write operations, like creating a client, will fail.
+	StaticClients []storage.Client `json:"staticClients"`
+
+	// If enabled, the server will maintain a list of passwords which can be used
+	// to identify a user.
+	EnablePasswordDB bool `json:"enablePasswordDB"`
+
+	// StaticPasswords cause the server use this list of passwords rather than
+	// querying the storage. Cannot be specified without enabling a passwords
+	// database.
+	StaticPasswords []password `json:"staticPasswords"`
+}
+
+// Validate the configuration
+func (c Config) Validate() error {
+	// Fast checks. Perform these first for a more responsive CLI.
+	checks := []struct {
+		bad    bool
+		errMsg string
+	}{
+		{c.Issuer == "", "no issuer specified in config file"},
+		{!c.EnablePasswordDB && len(c.StaticPasswords) != 0, "cannot specify static passwords without enabling password db"},
+		{c.Web.HTTP == "" && c.Web.HTTPS == "", "must supply a HTTP/HTTPS  address to listen on"},
+		{c.Web.HTTPS != "" && c.Web.TLSCert == "", "no cert specified for HTTPS"},
+		{c.Web.HTTPS != "" && c.Web.TLSKey == "", "no private key specified for HTTPS"},
+		{c.GRPC.TLSCert != "" && c.GRPC.Addr == "", "no address specified for gRPC"},
+		{c.GRPC.TLSKey != "" && c.GRPC.Addr == "", "no address specified for gRPC"},
+		{(c.GRPC.TLSCert == "") != (c.GRPC.TLSKey == ""), "must specific both a gRPC TLS cert and key"},
+		{c.GRPC.TLSCert == "" && c.GRPC.TLSClientCA != "", "cannot specify gRPC TLS client CA without a gRPC TLS cert"},
+	}
+
+	var checkErrors []string
+
+	for _, check := range checks {
+		if check.bad {
+			checkErrors = append(checkErrors, check.errMsg)
+		}
+	}
+	if len(checkErrors) != 0 {
+		return fmt.Errorf("invalid Config:\n\t-\t%s", strings.Join(checkErrors, "\n\t-\t"))
+	}
+	return nil
+}
+
+type password storage.Password
+
+func (p *password) UnmarshalJSON(b []byte) error {
+	var data struct {
+		Email       string `json:"email"`
+		Username    string `json:"username"`
+		UserID      string `json:"userID"`
+		Hash        string `json:"hash"`
+		HashFromEnv string `json:"hashFromEnv"`
+	}
+	if err := json.Unmarshal(b, &data); err != nil {
+		return err
+	}
+	*p = password(storage.Password{
+		Email:    data.Email,
+		Username: data.Username,
+		UserID:   data.UserID,
+	})
+	if len(data.Hash) == 0 && len(data.HashFromEnv) > 0 {
+		data.Hash = os.Getenv(data.HashFromEnv)
+	}
+	if len(data.Hash) == 0 {
+		return fmt.Errorf("no password hash provided")
+	}
+
+	// If this value is a valid bcrypt, use it.
+	_, bcryptErr := bcrypt.Cost([]byte(data.Hash))
+	if bcryptErr == nil {
+		p.Hash = []byte(data.Hash)
+		return nil
+	}
+
+	// For backwards compatibility try to base64 decode this value.
+	hashBytes, err := base64.StdEncoding.DecodeString(data.Hash)
+	if err != nil {
+		return fmt.Errorf("malformed bcrypt hash: %v", bcryptErr)
+	}
+	if _, err := bcrypt.Cost(hashBytes); err != nil {
+		return fmt.Errorf("malformed bcrypt hash: %v", err)
+	}
+	p.Hash = hashBytes
+	return nil
+}
+
+// OAuth2 describes enabled OAuth2 extensions.
+type OAuth2 struct {
+	ResponseTypes []string `json:"responseTypes"`
+	// If specified, do not prompt the user to approve client authorization. The
+	// act of logging in implies authorization.
+	SkipApprovalScreen bool `json:"skipApprovalScreen"`
+	// If specified, show the connector selection screen even if there's only one
+	AlwaysShowLoginScreen bool `json:"alwaysShowLoginScreen"`
+	// This is the connector that can be used for password grant
+	PasswordConnector string `json:"passwordConnector"`
+}
+
+// Web is the config format for the HTTP server.
+type Web struct {
+	HTTP           string   `json:"http"`
+	HTTPS          string   `json:"https"`
+	TLSCert        string   `json:"tlsCert"`
+	TLSKey         string   `json:"tlsKey"`
+	AllowedOrigins []string `json:"allowedOrigins"`
+}
+
+// Telemetry is the config format for telemetry including the HTTP server config.
+type Telemetry struct {
+	HTTP string `json:"http"`
+}
+
+// GRPC is the config for the gRPC API.
+type GRPC struct {
+	// The port to listen on.
+	Addr        string `json:"addr"`
+	TLSCert     string `json:"tlsCert"`
+	TLSKey      string `json:"tlsKey"`
+	TLSClientCA string `json:"tlsClientCA"`
+	Reflection  bool   `json:"reflection"`
+}
+
+// Storage holds app's storage configuration.
+type Storage struct {
+	Type   string            `json:"type"`
+	Config k8sstorage.Config `json:"config"`
+}
+
+// Connector is a magical type that can unmarshal YAML dynamically. The
+// Type field determines the connector type, which is then customized for Config.
+type Connector struct {
+	Type string `json:"type"`
+	Name string `json:"name"`
+	ID   string `json:"id"`
+
+	Config server.ConnectorConfig `json:"config"`
+}
+
+// UnmarshalJSON allows Connector to implement the unmarshaler interface to
+// dynamically determine the type of the connector config.
+func (c *Connector) UnmarshalJSON(b []byte) error {
+	var conn struct {
+		Type string `json:"type"`
+		Name string `json:"name"`
+		ID   string `json:"id"`
+
+		Config json.RawMessage `json:"config"`
+	}
+	if err := json.Unmarshal(b, &conn); err != nil {
+		return fmt.Errorf("parse connector: %v", err)
+	}
+	f, ok := server.ConnectorsConfig[conn.Type]
+	if !ok {
+		return fmt.Errorf("unknown connector type %q", conn.Type)
+	}
+
+	connConfig := f()
+	if len(conn.Config) != 0 {
+		data := []byte(os.ExpandEnv(string(conn.Config)))
+		if err := json.Unmarshal(data, connConfig); err != nil {
+			return fmt.Errorf("parse connector config: %v", err)
+		}
+	}
+	*c = Connector{
+		Type:   conn.Type,
+		Name:   conn.Name,
+		ID:     conn.ID,
+		Config: connConfig,
+	}
+	return nil
+}
+
+// Expiry holds configuration for the validity period of components.
+type Expiry struct {
+	// SigningKeys defines the duration of time after which the SigningKeys will be rotated.
+	SigningKeys string `json:"signingKeys"`
+
+	// IdTokens defines the duration of time for which the IdTokens will be valid.
+	IDTokens string `json:"idTokens"`
+
+	// AuthRequests defines the duration of time for which the AuthRequests will be valid.
+	AuthRequests string `json:"authRequests"`
+
+	// DeviceRequests defines the duration of time for which the DeviceRequests will be valid.
+	DeviceRequests string `json:"deviceRequests"`
+}
+
+// Logger holds configuration required to customize logging for dex.
+type Logger struct {
+	// Level sets logging level severity.
+	Level string `json:"level"`
+
+	// Format specifies the format to be used for logging.
+	Format string `json:"format"`
+}
diff --git a/pkg/identity/types/types.go b/pkg/identity/types/types.go
new file mode 100644
index 000000000..ed778a0be
--- /dev/null
+++ b/pkg/identity/types/types.go
@@ -0,0 +1,21 @@
+package types
+
+import (
+	dextypes "github.com/replicatedhq/kots/pkg/identity/types/dex"
+	ingresstypes "github.com/replicatedhq/kots/pkg/ingress/types"
+	rbactypes "github.com/replicatedhq/kots/pkg/rbac/types"
+)
+
+type Config struct {
+	Enabled             bool     `json:"enabled" yaml:"enabled"`
+	DisablePasswordAuth bool     `json:"disablePasswordAuth" yaml:"disablePasswordAuth"`
+	RestrictedGroups    []string `json:"restrictedGroups,omitempty" yaml:"restrictedGroups,omitempty"`
+	EnableAdvancedRBAC  bool     `json:"enableAdvancedRbac,omitempty" yaml:"enableAdvancedRbac,omitempty"`
+	RBAC                struct {
+		Groups   []rbactypes.Group  `json:"groups,omitempty" yaml:"groups,omitempty"`
+		Roles    []rbactypes.Role   `json:"roles,omitempty" yaml:"roles,omitempty"`
+		Policies []rbactypes.Policy `json:"policies,omitempty" yaml:"policies,omitempty"`
+	} `json:"rbac,omitempty" yaml:"rbac,omitempty"`
+	IngressConfig ingresstypes.Config  `json:"ingressConfig" yaml:"ingressConfig"`
+	DexConnectors []dextypes.Connector `json:"dexConnectors,omitempty" yaml:"dexConnectors,omitempty"`
+}
diff --git a/pkg/identity/undeploy.go b/pkg/identity/undeploy.go
new file mode 100644
index 000000000..e2de2ff28
--- /dev/null
+++ b/pkg/identity/undeploy.go
@@ -0,0 +1,59 @@
+package identity
+
+import (
+	"context"
+
+	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/pkg/logger"
+	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+)
+
+func Undeploy(ctx context.Context, logger *logger.Logger, clientset kubernetes.Interface, namespace string) error {
+	if err := deleteIngress(ctx, clientset, namespace); err != nil {
+		return errors.Wrap(err, "failed to delete ingress")
+	}
+	if err := deleteService(ctx, clientset, namespace); err != nil {
+		return errors.Wrap(err, "failed to delete service")
+	}
+	if err := deleteDeployment(ctx, clientset, namespace); err != nil {
+		return errors.Wrap(err, "failed to delete deployment")
+	}
+	if err := deleteSecret(ctx, clientset, namespace); err != nil {
+		return errors.Wrap(err, "failed to delete secret")
+	}
+	return nil
+}
+
+func deleteIngress(ctx context.Context, clientset kubernetes.Interface, namespace string) error {
+	err := clientset.ExtensionsV1beta1().Ingresses(namespace).Delete(ctx, DexIngressName, metav1.DeleteOptions{})
+	if kuberneteserrors.IsNotFound(err) {
+		err = nil
+	}
+	return errors.Wrap(err, "failed to delete ingress")
+}
+
+func deleteService(ctx context.Context, clientset kubernetes.Interface, namespace string) error {
+	err := clientset.CoreV1().Services(namespace).Delete(ctx, DexServiceName, metav1.DeleteOptions{})
+	if kuberneteserrors.IsNotFound(err) {
+		err = nil
+	}
+	return errors.Wrap(err, "failed to delete service")
+}
+
+func deleteDeployment(ctx context.Context, clientset kubernetes.Interface, namespace string) error {
+	err := clientset.AppsV1().Deployments(namespace).Delete(ctx, DexDeploymentName, metav1.DeleteOptions{})
+	if kuberneteserrors.IsNotFound(err) {
+		err = nil
+	}
+	return errors.Wrap(err, "failed to delete deployment")
+}
+
+func deleteSecret(ctx context.Context, clientset kubernetes.Interface, namespace string) error {
+	err := clientset.CoreV1().Secrets(namespace).Delete(ctx, DexSecretName, metav1.DeleteOptions{})
+	if kuberneteserrors.IsNotFound(err) {
+		err = nil
+	}
+	return errors.Wrap(err, "failed to delete secret")
+}
diff --git a/pkg/ingress/config.go b/pkg/ingress/config.go
new file mode 100644
index 000000000..e224a61cf
--- /dev/null
+++ b/pkg/ingress/config.go
@@ -0,0 +1,108 @@
+package ingress
+
+import (
+	"context"
+
+	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/pkg/ingress/types"
+	kotsadmtypes "github.com/replicatedhq/kots/pkg/kotsadm/types"
+	"gopkg.in/yaml.v2"
+	corev1 "k8s.io/api/core/v1"
+	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+	k8sconfig "sigs.k8s.io/controller-runtime/pkg/client/config"
+)
+
+var (
+	ConfigMapName = "kotsadm-ingress-config"
+)
+
+func GetConfig(ctx context.Context, namespace string) (*types.Config, error) {
+	cfg, err := k8sconfig.GetConfig()
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get kubernetes config")
+	}
+
+	clientset, err := kubernetes.NewForConfig(cfg)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get client set")
+	}
+
+	config := &types.Config{}
+
+	configMap, err := clientset.CoreV1().ConfigMaps(namespace).Get(ctx, ConfigMapName, metav1.GetOptions{})
+	if err != nil {
+		if kuberneteserrors.IsNotFound(err) {
+			return config, nil
+		}
+		return nil, errors.Wrap(err, "failed to get config map")
+	}
+
+	err = yaml.Unmarshal([]byte(configMap.Data["config.yaml"]), &config)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to unmarshal config")
+	}
+
+	return config, err
+}
+
+func SetConfig(ctx context.Context, namespace string, config types.Config) error {
+	cfg, err := k8sconfig.GetConfig()
+	if err != nil {
+		return errors.Wrap(err, "failed to get kubernetes config")
+	}
+
+	clientset, err := kubernetes.NewForConfig(cfg)
+	if err != nil {
+		return errors.Wrap(err, "failed to get client set")
+	}
+
+	configMap, err := ingressConfigResource(config)
+	if err != nil {
+		return err
+	}
+
+	existingConfigMap, err := clientset.CoreV1().ConfigMaps(namespace).Get(ctx, ConfigMapName, metav1.GetOptions{})
+	if err != nil {
+		if !kuberneteserrors.IsNotFound(err) {
+			return errors.Wrap(err, "failed to get config map")
+		}
+
+		_, err = clientset.CoreV1().ConfigMaps(namespace).Create(ctx, configMap, metav1.CreateOptions{})
+		if err != nil {
+			return errors.Wrap(err, "failed to create config map")
+		}
+
+		return nil
+	}
+
+	existingConfigMap.Data = configMap.Data
+
+	_, err = clientset.CoreV1().ConfigMaps(namespace).Update(ctx, existingConfigMap, metav1.UpdateOptions{})
+	if err != nil {
+		return errors.Wrap(err, "failed to update config map")
+	}
+
+	return nil
+}
+
+func ingressConfigResource(config types.Config) (*corev1.ConfigMap, error) {
+	data, err := yaml.Marshal(config)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to marshal config")
+	}
+	return &corev1.ConfigMap{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "v1",
+			Kind:       "ConfigMap",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   ConfigMapName,
+			Labels: kotsadmtypes.GetKotsadmLabels(),
+		},
+		Data: map[string]string{
+			"config.yaml": string(data),
+		},
+	}, nil
+}
diff --git a/pkg/ingress/deploy.go b/pkg/ingress/deploy.go
new file mode 100644
index 000000000..414d4a795
--- /dev/null
+++ b/pkg/ingress/deploy.go
@@ -0,0 +1,44 @@
+package ingress
+
+import (
+	"context"
+
+	"github.com/pkg/errors"
+	extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
+	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+)
+
+func EnsureIngress(ctx context.Context, clientset kubernetes.Interface, namespace string, ingress *extensionsv1beta1.Ingress) error {
+	existing, err := clientset.ExtensionsV1beta1().Ingresses(namespace).Get(ctx, ingress.Name, metav1.GetOptions{})
+	if err != nil {
+		if !kuberneteserrors.IsNotFound(err) {
+			return errors.Wrap(err, "failed to get existing ingress")
+		}
+
+		_, err = clientset.ExtensionsV1beta1().Ingresses(namespace).Create(ctx, ingress, metav1.CreateOptions{})
+		if err != nil {
+			return errors.Wrap(err, "failed to create ingress")
+		}
+
+		return nil
+	}
+
+	existing = updateIngress(existing, ingress)
+
+	_, err = clientset.ExtensionsV1beta1().Ingresses(namespace).Update(ctx, existing, metav1.UpdateOptions{})
+	if err != nil {
+		return errors.Wrap(err, "failed to update ingress")
+	}
+
+	return nil
+}
+
+func updateIngress(existing, desiredIngress *extensionsv1beta1.Ingress) *extensionsv1beta1.Ingress {
+	existing.Annotations = desiredIngress.Annotations
+	existing.Spec.Rules = desiredIngress.Spec.Rules
+	existing.Spec.TLS = desiredIngress.Spec.TLS
+
+	return existing
+}
diff --git a/pkg/ingress/ingress.go b/pkg/ingress/ingress.go
new file mode 100644
index 000000000..fcf76e84f
--- /dev/null
+++ b/pkg/ingress/ingress.go
@@ -0,0 +1,41 @@
+package ingress
+
+import (
+	"net/url"
+	"strings"
+
+	"github.com/replicatedhq/kots/pkg/ingress/types"
+)
+
+func GetAddress(config types.Config) string {
+	switch {
+	case config.Ingress != nil:
+		return getIngressConfigAddress(*config.Ingress)
+
+	case config.NodePort != nil:
+		return strings.TrimRight(config.NodePort.Address, "/")
+
+	case config.External != nil:
+		return strings.TrimRight(config.External.Address, "/")
+	}
+
+	return ""
+}
+
+func getIngressConfigAddress(ingressConfig types.IngressConfig) string {
+	if ingressConfig.Address != "" {
+		return strings.TrimRight(ingressConfig.Address, "/")
+	}
+
+	var u url.URL
+	if ingressConfig.TLSSecretName != "" {
+		u.Scheme = "https"
+	} else {
+		u.Scheme = "http"
+	}
+
+	u.Host = ingressConfig.Host
+	u.Path = ingressConfig.Path
+
+	return strings.TrimRight(u.String(), "/")
+}
diff --git a/pkg/ingress/ingress_config.go b/pkg/ingress/ingress_config.go
new file mode 100644
index 000000000..ca38c3908
--- /dev/null
+++ b/pkg/ingress/ingress_config.go
@@ -0,0 +1,55 @@
+package ingress
+
+import (
+	"github.com/replicatedhq/kots/pkg/ingress/types"
+	kotsadmtypes "github.com/replicatedhq/kots/pkg/kotsadm/types"
+	extensions "k8s.io/api/extensions/v1beta1"
+	extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/intstr"
+)
+
+func IngressFromConfig(ingressConfig types.IngressConfig, name string, serviceName string, servicePort int, additionalLabels map[string]string) *extensionsv1beta1.Ingress {
+	ingressTLS := []extensions.IngressTLS{}
+	if ingressConfig.TLSSecretName != "" {
+		tls := extensions.IngressTLS{
+			SecretName: ingressConfig.TLSSecretName,
+		}
+		if ingressConfig.Host != "" {
+			tls.Hosts = append(tls.Hosts, ingressConfig.Host)
+		}
+		ingressTLS = append(ingressTLS, tls)
+	}
+	return &extensionsv1beta1.Ingress{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "v1beta1",
+			Kind:       "Ingress",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:        name,
+			Labels:      kotsadmtypes.GetKotsadmLabels(additionalLabels),
+			Annotations: ingressConfig.Annotations,
+		},
+		Spec: extensionsv1beta1.IngressSpec{
+			Rules: []extensionsv1beta1.IngressRule{
+				{
+					Host: ingressConfig.Host,
+					IngressRuleValue: extensionsv1beta1.IngressRuleValue{
+						HTTP: &extensionsv1beta1.HTTPIngressRuleValue{
+							Paths: []extensionsv1beta1.HTTPIngressPath{
+								{
+									Path: ingressConfig.Path,
+									Backend: extensionsv1beta1.IngressBackend{
+										ServiceName: serviceName,
+										ServicePort: intstr.FromInt(servicePort),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			TLS: ingressTLS,
+		},
+	}
+}
diff --git a/pkg/ingress/types/types.go b/pkg/ingress/types/types.go
new file mode 100644
index 000000000..faed1c608
--- /dev/null
+++ b/pkg/ingress/types/types.go
@@ -0,0 +1,26 @@
+package types
+
+type Config struct {
+	Enabled  bool            `json:"enabled" yaml:"enabled"`
+	Ingress  *IngressConfig  `json:"ingress,omitempty" yaml:"ingress,omitempty"`
+	NodePort *NodePortConfig `json:"nodePort,omitempty" yaml:"nodePort,omitempty"`
+	External *ExternalConfig `json:"external,omitempty" yaml:"external,omitempty"`
+	// TODO: Service type LoadBalancer
+}
+
+type IngressConfig struct {
+	Address       string            `json:"address,omitempty" yaml:"address,omitempty"` // if address is empty it is inferred
+	Path          string            `json:"path" yaml:"path"`
+	Host          string            `json:"host" yaml:"host"`
+	TLSSecretName string            `json:"tlsSecretName,omitempty" yaml:"tlsSecretName,omitempty"`
+	Annotations   map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"`
+}
+
+type NodePortConfig struct {
+	Address string `json:"address" yaml:"address"`
+	Port    int    `json:"port" yaml:"port"`
+}
+
+type ExternalConfig struct {
+	Address string `json:"address" yaml:"address"`
+}
diff --git a/pkg/kotsadm/ingress.go b/pkg/kotsadm/ingress.go
new file mode 100644
index 000000000..cf5c640dc
--- /dev/null
+++ b/pkg/kotsadm/ingress.go
@@ -0,0 +1,28 @@
+package kotsadm
+
+import (
+	"context"
+
+	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/pkg/ingress"
+	ingresstypes "github.com/replicatedhq/kots/pkg/ingress/types"
+	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+)
+
+func EnsureIngress(ctx context.Context, namespace string, clientset *kubernetes.Clientset, ingressConfig ingresstypes.Config) error {
+	if ingressConfig.Ingress == nil {
+		return DeleteIngress(ctx, namespace, clientset)
+	}
+	kotsadmIngress := kotsadmIngress(namespace, *ingressConfig.Ingress)
+	return ingress.EnsureIngress(ctx, clientset, namespace, kotsadmIngress)
+}
+
+func DeleteIngress(ctx context.Context, namespace string, clientset *kubernetes.Clientset) error {
+	err := clientset.ExtensionsV1beta1().Ingresses(namespace).Delete(ctx, "kotsadm", metav1.DeleteOptions{})
+	if kuberneteserrors.IsNotFound(err) {
+		err = nil
+	}
+	return errors.Wrap(err, "failed to delete ingress")
+}
diff --git a/pkg/kotsadm/kotsadm.go b/pkg/kotsadm/kotsadm.go
index 9f519df9f..6aac37383 100644
--- a/pkg/kotsadm/kotsadm.go
+++ b/pkg/kotsadm/kotsadm.go
@@ -47,12 +47,20 @@ func getKotsadmYAML(deployOptions types.DeployOptions) (map[string][]byte, error
 	}
 	docs["kotsadm-deployment.yaml"] = deployment.Bytes()
 
+	var nodePort int32
+	if deployOptions.IngressConfig.Enabled && deployOptions.IngressConfig.NodePort != nil {
+		nodePort = int32(deployOptions.IngressConfig.NodePort.Port)
+	}
+
 	var service bytes.Buffer
-	if err := s.Encode(kotsadmService(deployOptions.Namespace), &service); err != nil {
+	if err := s.Encode(kotsadmService(deployOptions.Namespace, nodePort), &service); err != nil {
 		return nil, errors.Wrap(err, "failed to marshal kotsadm service")
 	}
 	docs["kotsadm-service.yaml"] = service.Bytes()
 
+	// TODO (ethan): ingress
+	// TODO (ethan): identity-service
+
 	return docs, nil
 }
 
@@ -143,7 +151,12 @@ func ensureKotsadmComponent(deployOptions *types.DeployOptions, clientset *kuber
 		return errors.Wrap(err, "failed to ensure kotsadm deployment")
 	}
 
-	if err := ensureKotsadmService(deployOptions.Namespace, clientset); err != nil {
+	var nodePort int32
+	if deployOptions.IngressConfig.Enabled && deployOptions.IngressConfig.NodePort != nil {
+		nodePort = int32(deployOptions.IngressConfig.NodePort.Port)
+	}
+
+	if err := ensureKotsadmService(deployOptions.Namespace, clientset, nodePort); err != nil {
 		return errors.Wrap(err, "failed to ensure kotsadm service")
 	}
 
@@ -316,22 +329,37 @@ func ensureKotsadmDeployment(deployOptions types.DeployOptions, clientset *kuber
 	return nil
 }
 
-func ensureKotsadmService(namespace string, clientset *kubernetes.Clientset) error {
-	_, err := clientset.CoreV1().Services(namespace).Get(context.TODO(), "kotsadm", metav1.GetOptions{})
+func ensureKotsadmService(namespace string, clientset *kubernetes.Clientset, nodePort int32) error {
+	service := kotsadmService(namespace, nodePort)
+
+	existing, err := clientset.CoreV1().Services(namespace).Get(context.TODO(), "kotsadm", metav1.GetOptions{})
 	if err != nil {
 		if !kuberneteserrors.IsNotFound(err) {
 			return errors.Wrap(err, "failed to get existing service")
 		}
 
-		_, err := clientset.CoreV1().Services(namespace).Create(context.TODO(), kotsadmService(namespace), metav1.CreateOptions{})
+		_, err := clientset.CoreV1().Services(namespace).Create(context.TODO(), service, metav1.CreateOptions{})
 		if err != nil {
-			return errors.Wrap(err, "Failed to create service")
+			return errors.Wrap(err, "failed to create service")
 		}
 	}
 
+	existing = updateKotsadmService(existing, service)
+
+	_, err = clientset.CoreV1().Services(namespace).Update(context.TODO(), existing, metav1.UpdateOptions{})
+	if err != nil {
+		return errors.Wrap(err, "failed to update service")
+	}
+
 	return nil
 }
 
+func updateKotsadmService(existing, desiredService *corev1.Service) *corev1.Service {
+	existing.Spec.Ports = desiredService.Spec.Ports
+
+	return existing
+}
+
 // isKotsadmClusterScoped determines if the kotsadm pod should be running
 // with cluster-wide permissions or not
 func isKotsadmClusterScoped(applicationMetadata []byte) (bool, error) {
diff --git a/pkg/kotsadm/kotsadm_objects.go b/pkg/kotsadm/kotsadm_objects.go
index 08814af3d..7471a6db3 100644
--- a/pkg/kotsadm/kotsadm_objects.go
+++ b/pkg/kotsadm/kotsadm_objects.go
@@ -6,10 +6,13 @@ import (
 	"strings"
 
 	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/pkg/ingress"
+	ingresstypes "github.com/replicatedhq/kots/pkg/ingress/types"
 	"github.com/replicatedhq/kots/pkg/kotsadm/types"
 	"github.com/replicatedhq/kots/pkg/util"
 	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
+	extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
 	rbacv1 "k8s.io/api/rbac/v1"
 	"k8s.io/apimachinery/pkg/api/resource"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -543,14 +546,18 @@ func kotsadmDeployment(deployOptions types.DeployOptions) *appsv1.Deployment {
 	return deployment
 }
 
-func kotsadmService(namespace string) *corev1.Service {
+func kotsadmService(namespace string, nodePort int32) *corev1.Service {
 	port := corev1.ServicePort{
 		Name:       "http",
 		Port:       3000,
 		TargetPort: intstr.FromString("http"),
+		NodePort:   nodePort,
 	}
 
 	serviceType := corev1.ServiceTypeClusterIP
+	if nodePort != 0 {
+		serviceType = corev1.ServiceTypeNodePort
+	}
 
 	service := &corev1.Service{
 		TypeMeta: metav1.TypeMeta{
@@ -575,3 +582,7 @@ func kotsadmService(namespace string) *corev1.Service {
 
 	return service
 }
+
+func kotsadmIngress(namespace string, ingressConfig ingresstypes.IngressConfig) *extensionsv1beta1.Ingress {
+	return ingress.IngressFromConfig(ingressConfig, "kotsadm", "kotsadm", 3000, nil)
+}
diff --git a/pkg/kotsadm/main.go b/pkg/kotsadm/main.go
index a41d8d455..3271b90b8 100644
--- a/pkg/kotsadm/main.go
+++ b/pkg/kotsadm/main.go
@@ -11,6 +11,8 @@ import (
 	"github.com/pkg/errors"
 	kotsscheme "github.com/replicatedhq/kots/kotskinds/client/kotsclientset/scheme"
 	"github.com/replicatedhq/kots/pkg/docker/registry"
+	"github.com/replicatedhq/kots/pkg/identity"
+	"github.com/replicatedhq/kots/pkg/ingress"
 	"github.com/replicatedhq/kots/pkg/k8sutil"
 	"github.com/replicatedhq/kots/pkg/kotsadm/types"
 	"github.com/replicatedhq/kots/pkg/logger"
@@ -325,6 +327,10 @@ func ensureStorage(deployOptions types.DeployOptions, clientset *kubernetes.Clie
 func ensureKotsadm(deployOptions types.DeployOptions, clientset *kubernetes.Clientset, log *logger.Logger) error {
 	restartKotsadmAPI := false
 
+	if !deployOptions.IngressConfig.Enabled && deployOptions.IdentityConfig.Enabled {
+		return errors.New("KOTS identity service requires ingress to be enabled")
+	}
+
 	existingDeployment, err := clientset.AppsV1().Deployments(deployOptions.Namespace).Get(context.TODO(), "kotsadm", metav1.GetOptions{})
 	if err != nil && !kuberneteserrors.IsNotFound(err) {
 		return errors.Wrap(err, "failed to get existing deployment")
@@ -413,7 +419,39 @@ func ensureKotsadm(deployOptions types.DeployOptions, clientset *kubernetes.Clie
 		if err := ensureDisasterRecoveryLabels(&deployOptions, clientset); err != nil {
 			return errors.Wrap(err, "failed to ensure disaster recovery labels")
 		}
+	}
+
+	if deployOptions.IngressConfig.Enabled {
+		ctx := context.TODO()
+
+		log.ChildActionWithSpinner("Enabling ingress for the Admin Console")
+
+		if err := ingress.SetConfig(ctx, deployOptions.Namespace, deployOptions.IngressConfig); err != nil {
+			return errors.Wrap(err, "failed to set identity config")
+		}
+
+		if err := EnsureIngress(ctx, deployOptions.Namespace, clientset, deployOptions.IngressConfig); err != nil {
+			return errors.Wrap(err, "failed to ensure ingress")
+		}
+		log.FinishSpinner()
 
+		if deployOptions.IdentityConfig.Enabled {
+			log.ChildActionWithSpinner("Deploying the Identity Service")
+
+			deployOptions.IdentityConfig.DisablePasswordAuth = true
+
+			if err := identity.SetConfig(ctx, deployOptions.Namespace, deployOptions.IdentityConfig); err != nil {
+				return errors.Wrap(err, "failed to set identity config")
+			}
+
+			if err := identity.Deploy(ctx, log, clientset, deployOptions.Namespace, deployOptions.IdentityConfig, deployOptions.IngressConfig); err != nil {
+				return errors.Wrap(err, "failed to deploy identity service")
+			}
+			log.FinishSpinner()
+		}
+	}
+
+	if !deployOptions.ExcludeAdminConsole {
 		log.ChildActionWithSpinner("Waiting for Admin Console to be ready")
 		if err := waitForKotsadm(&deployOptions, existingDeployment, clientset); err != nil {
 			return errors.Wrap(err, "failed to wait for web")
diff --git a/pkg/kotsadm/types/deployoptions.go b/pkg/kotsadm/types/deployoptions.go
index 29507b1d2..4d1286de1 100644
--- a/pkg/kotsadm/types/deployoptions.go
+++ b/pkg/kotsadm/types/deployoptions.go
@@ -5,6 +5,8 @@ import (
 	"time"
 
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
+	identitytypes "github.com/replicatedhq/kots/pkg/identity/types"
+	ingresstypes "github.com/replicatedhq/kots/pkg/ingress/types"
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 )
@@ -43,5 +45,8 @@ type DeployOptions struct {
 	ExcludeAdminConsole       bool
 	EnsureKotsadmConfig       bool
 
+	IdentityConfig identitytypes.Config
+	IngressConfig  ingresstypes.Config
+
 	KotsadmOptions KotsadmOptions
 }
diff --git a/pkg/pull/public_keys.go b/pkg/pull/public_keys.go
index 88e14381c..6bee8182a 100644
--- a/pkg/pull/public_keys.go
+++ b/pkg/pull/public_keys.go
@@ -9,7 +9,7 @@ xKs57d8MTWUTVh3W3WYi6LbqAPScdmSiG7A145HhKXmmtZFEv4puE5dKmS5lkV2d
 VU789XWrNFk74FKKHVwYMdppqAabB6cRBmU8YFiVEULOn+d1FtKRbO/vv/fbA9nX
 PUG/1PgEQHogP+3cC4J7b7s9+kBmtHkpSq9x+OUu/5B+nT21dooS6adfQiI8iB/+
 NQIDAQAB
------END PUBLIC KEY-----`),
+-----END PUBLIC KEY-----`), // Dev
 
 	"bdee56560cfb43c9b28bf98eacafa646": []byte(`-----BEGIN PUBLIC KEY-----
 MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwdSHE8v64QH/yELBoPBl
@@ -19,7 +19,7 @@ mU1jG/oVlQkRoyOYrObTeoD0BdcZAr2PdGvgvJvpZduZtrKvjvsSJEBYExoPtko+
 8AqhMBAI+qX1/SMix21qpmYSYLNeqN2Pplna0p2MK8yyaHY8KSqTF90ZJF1+P0ZF
 MLt6S8/6PIX9WD+vFqmDpW1GCkB+p2OfxsYiAIX1ej98Ck3hoPQnOuiFIovV8aFQ
 bQIDAQAB
------END PUBLIC KEY-----`),
+-----END PUBLIC KEY-----`), // Production
 
 	"de2c275656d04b1bb0f15cf70f0ea2a2": []byte(`-----BEGIN PUBLIC KEY-----
 MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2hHg1HER6NYlsqBs+B+B
@@ -29,5 +29,5 @@ q3pGs8OaAc0dop/tqUE7WqQfHLWJdTPP5pVDLDWybfAO4OmgVmx+oVXdCfMVlOzu
 num6SOF+eBuERXQGbEfnd6eSRVokWhfMCfXNPTYtq14DaK9tvX4uzHsub+Asn6UN
 OBIAESJntpZfdDDrNqbfOQYql2rqx1lJtU7lVFbTQTkKhj4teInEGO6FvLzy0UE9
 swIDAQAB
------END PUBLIC KEY-----`),
+-----END PUBLIC KEY-----`), // Staging
 }
diff --git a/pkg/pull/signature.go b/pkg/pull/signature.go
index 2cc3d9161..8067df83f 100644
--- a/pkg/pull/signature.go
+++ b/pkg/pull/signature.go
@@ -59,10 +59,12 @@ func VerifySignature(license *kotsv1beta1.License) (*kotsv1beta1.License, error)
 		return nil, errors.New("unknown global key")
 	}
 
+	// verify that the app public key is properly signed with a replicated private key
 	if err := verify([]byte(innerSignature.PublicKey), keySignature.Signature, globalKeyPEM); err != nil {
 		return nil, errors.Wrap(err, "failed to verify key signature")
 	}
 
+	// verify that the license data is properly signed with the app private key
 	if err := verify(outerSignature.LicenseData, innerSignature.LicenseSignature, []byte(innerSignature.PublicKey)); err != nil {
 		return nil, errors.Wrap(err, "failed to verify license signature")
 	}
diff --git a/pkg/rbac/rbac.go b/pkg/rbac/rbac.go
new file mode 100644
index 000000000..db4e85c46
--- /dev/null
+++ b/pkg/rbac/rbac.go
@@ -0,0 +1,35 @@
+package rbac
+
+import (
+	"github.com/replicatedhq/kots/pkg/rbac/types"
+)
+
+const (
+	WildcardGroupID    = "*"
+	ClusterAdminRoleID = "cluster-admin"
+)
+
+var (
+	DefaultGroups   = []types.Group{DefaultGroup}
+	DefaultRoles    = []types.Role{ClusterAdminRole}
+	DefaultPolicies = []types.Policy{PolicyAllowAll}
+
+	DefaultGroup = types.Group{
+		ID:      WildcardGroupID,
+		RoleIDs: []string{ClusterAdminRoleID},
+	}
+
+	ClusterAdminRole = types.Role{
+		ID:          ClusterAdminRoleID,
+		Name:        "Cluster Admin",
+		Description: "Read/write access to all resounces",
+		PolicyIDs:   []string{"allow-all"},
+	}
+
+	PolicyAllowAll = types.Policy{
+		ID: "allow-all",
+		Allowed: []string{
+			"**/*",
+		},
+	}
+)
diff --git a/pkg/rbac/types/types.go b/pkg/rbac/types/types.go
new file mode 100644
index 000000000..a099c8fc9
--- /dev/null
+++ b/pkg/rbac/types/types.go
@@ -0,0 +1,21 @@
+package types
+
+type Role struct {
+	ID          string   `json:"id" yaml:"id"`
+	Name        string   `json:"name,omitempty" yaml:"name,omitempty"`
+	Description string   `json:"description,omitempty" yaml:"description,omitempty"`
+	PolicyIDs   []string `json:"policyIds" yaml:"policyIds"`
+}
+
+type Policy struct {
+	ID          string   `json:"id" yaml:"id"`
+	Name        string   `json:"name,omitempty" yaml:"name,omitempty"`
+	Description string   `json:"description,omitempty" yaml:"description,omitempty"`
+	Allowed     []string `json:"allowed,omitempty" yaml:"allowed,omitempty"`
+	Denied      []string `json:"denied,omitempty" yaml:"denied,omitempty"`
+}
+
+type Group struct {
+	ID      string   `json:"id" yaml:"id"`
+	RoleIDs []string `json:"roleIds" yaml:"roleIds"`
+}
diff --git a/pkg/template/kurl_context.go b/pkg/template/kurl_context.go
index 2d879aa5d..18fb84718 100644
--- a/pkg/template/kurl_context.go
+++ b/pkg/template/kurl_context.go
@@ -15,25 +15,37 @@ import (
 	k8sconfig "sigs.k8s.io/controller-runtime/pkg/client/config"
 )
 
-func GetKurlValues(installerName, nameSpace string) *kurlv1beta1.Installer {
-
+// getKurlValues returns the values found in the specified installer and namespace, if it exists
+// otherwise it returns the values found in the first installer in the specified namespace, if one exists
+// otherwise it returns nil
+func getKurlValues(installerName, nameSpace string) *kurlv1beta1.Installer {
 	cfg, err := k8sconfig.GetConfig()
-
 	if err != nil {
 		return nil
 	}
 
 	clientset := kurlclientset.NewForConfigOrDie(cfg)
-
 	installers := clientset.Installers(nameSpace)
 
 	retrieved, err := installers.Get(context.TODO(), installerName, metav1.GetOptions{})
+	if err == nil && retrieved != nil {
+		return retrieved
+	}
 
+	allInstallers, err := installers.List(context.TODO(), metav1.ListOptions{})
 	if err != nil {
 		return nil
 	}
-
-	return retrieved
+	if allInstallers == nil || len(allInstallers.Items) == 0 {
+		return nil
+	}
+	newestInstaller := allInstallers.Items[0]
+	for _, installer := range allInstallers.Items {
+		if installer.CreationTimestamp.After(newestInstaller.CreationTimestamp.Time) {
+			newestInstaller = installer
+		}
+	}
+	return &newestInstaller
 }
 
 func newKurlContext(installerName, nameSpace string) *kurlCtx {
@@ -41,7 +53,7 @@ func newKurlContext(installerName, nameSpace string) *kurlCtx {
 		KurlValues: make(map[string]interface{}),
 	}
 
-	retrieved := GetKurlValues(installerName, nameSpace)
+	retrieved := getKurlValues(installerName, nameSpace)
 
 	if retrieved != nil {
 		ctx.AddValuesToKurlContext(retrieved)


Test patch:
diff --git a/kotsadm/pkg/handlers/session_test.go b/kotsadm/pkg/handlers/session_test.go
new file mode 100644
index 000000000..dbc1fdc8c
--- /dev/null
+++ b/kotsadm/pkg/handlers/session_test.go
@@ -0,0 +1,44 @@
+package handlers
+
+import (
+	"testing"
+
+	"github.com/replicatedhq/kots/kotsadm/pkg/session/types"
+)
+
+func Test_sessionAuthorize(t *testing.T) {
+	tests := []struct {
+		name     string
+		resource string
+		roles    []types.SessionRole
+		want     bool
+	}{
+		{
+			name:     "allowed",
+			resource: "/apps",
+			roles: []types.SessionRole{{
+				Policies: []types.SessionPolicy{{
+					Allowed: []string{"**/*"},
+				}},
+			}},
+			want: true,
+		},
+		{
+			name:     "denied",
+			resource: "/apps",
+			roles: []types.SessionRole{{
+				Policies: []types.SessionPolicy{{
+					Denied: []string{"**/*"},
+				}},
+			}},
+			want: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := sessionAuthorize(tt.resource, tt.roles); got != tt.want {
+				t.Errorf("sessionAuthorize() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/kotsadm/pkg/session/session_test.go b/kotsadm/pkg/session/session_test.go
new file mode 100644
index 000000000..7b1032a7c
--- /dev/null
+++ b/kotsadm/pkg/session/session_test.go
@@ -0,0 +1,125 @@
+package session
+
+import (
+	"reflect"
+	"testing"
+
+	"github.com/replicatedhq/kots/kotsadm/pkg/session/types"
+	"github.com/replicatedhq/kots/pkg/rbac"
+	rbactypes "github.com/replicatedhq/kots/pkg/rbac/types"
+)
+
+func Test_GetSessionRolesFromRBAC(t *testing.T) {
+	type args struct {
+		sessionGroupIDs []string
+		groups          []rbactypes.Group
+		roles           []rbactypes.Role
+		policies        []rbactypes.Policy
+	}
+	tests := []struct {
+		name string
+		args args
+		want []types.SessionRole
+	}{
+		{
+			name: "wildcard",
+			args: args{
+				sessionGroupIDs: nil,
+				groups:          rbac.DefaultGroups,
+				roles:           rbac.DefaultRoles,
+				policies:        rbac.DefaultPolicies,
+			},
+			want: []types.SessionRole{
+				{
+					ID: rbac.ClusterAdminRoleID,
+					Policies: []types.SessionPolicy{
+						{
+							ID:      rbac.PolicyAllowAll.ID,
+							Allowed: rbac.PolicyAllowAll.Allowed,
+							Denied:  rbac.PolicyAllowAll.Denied,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "custom wildcard",
+			args: args{
+				sessionGroupIDs: []string{"custom_group"},
+				groups: append(rbac.DefaultGroups, rbactypes.Group{
+					ID:      "custom_group",
+					RoleIDs: []string{"custom_role"},
+				}),
+				roles: append(rbac.DefaultRoles, rbactypes.Role{
+					ID:        "custom_role",
+					PolicyIDs: []string{"custom_policy"},
+				}),
+				policies: append(rbac.DefaultPolicies, rbactypes.Policy{
+					ID:      "custom_policy",
+					Allowed: []string{"allow"},
+					Denied:  []string{"deny"},
+				}),
+			},
+			want: []types.SessionRole{
+				{
+					ID: rbac.ClusterAdminRoleID,
+					Policies: []types.SessionPolicy{
+						{
+							ID:      rbac.PolicyAllowAll.ID,
+							Allowed: rbac.PolicyAllowAll.Allowed,
+							Denied:  rbac.PolicyAllowAll.Denied,
+						},
+					},
+				},
+				{
+					ID: "custom_role",
+					Policies: []types.SessionPolicy{
+						{
+							ID:      "custom_policy",
+							Allowed: []string{"allow"},
+							Denied:  []string{"deny"},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "custom",
+			args: args{
+				sessionGroupIDs: []string{"custom_group"},
+				groups: []rbactypes.Group{{
+					ID:      "custom_group",
+					RoleIDs: []string{"custom_role"},
+				}},
+				roles: append(rbac.DefaultRoles, rbactypes.Role{
+					ID:        "custom_role",
+					PolicyIDs: []string{"custom_policy"},
+				}),
+				policies: append(rbac.DefaultPolicies, rbactypes.Policy{
+					ID:      "custom_policy",
+					Allowed: []string{"allow"},
+					Denied:  []string{"deny"},
+				}),
+			},
+			want: []types.SessionRole{
+				{
+					ID: "custom_role",
+					Policies: []types.SessionPolicy{
+						{
+							ID:      "custom_policy",
+							Allowed: []string{"allow"},
+							Denied:  []string{"deny"},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := GetSessionRolesFromRBAC(tt.args.sessionGroupIDs, tt.args.groups, tt.args.roles, tt.args.policies); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("GetSessionRolesFromRBAC() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/pkg/ingress/ingress_test.go b/pkg/ingress/ingress_test.go
new file mode 100644
index 000000000..baf5ea141
--- /dev/null
+++ b/pkg/ingress/ingress_test.go
@@ -0,0 +1,48 @@
+package ingress
+
+import (
+	"testing"
+
+	"github.com/replicatedhq/kots/pkg/ingress/types"
+)
+
+func Test_getIngressConfigAddress(t *testing.T) {
+	tests := []struct {
+		name          string
+		ingressConfig types.IngressConfig
+		want          string
+	}{
+		{
+			name: "address",
+			ingressConfig: types.IngressConfig{
+				Address: "http://somebigbank.com",
+				Path:    "/test",
+			},
+			want: "http://somebigbank.com",
+		},
+		{
+			name: "no address",
+			ingressConfig: types.IngressConfig{
+				Host: "somebigbank.com",
+				Path: "/test",
+			},
+			want: "http://somebigbank.com/test",
+		},
+		{
+			name: "no address tls",
+			ingressConfig: types.IngressConfig{
+				Host:          "somebigbank.com",
+				Path:          "/test",
+				TLSSecretName: "kotsadm-tls",
+			},
+			want: "https://somebigbank.com/test",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := getIngressConfigAddress(tt.ingressConfig); got != tt.want {
+				t.Errorf("getIngressConfigAddress() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}