Selaa lähdekoodia

change service start up to start only one of porter api/auth (not both); add db connection to auth service; implement API token for porter-agent (#3282)

Co-authored-by: David Townley <davidtownley@Davids-MacBook-Air.local>
d-g-town 2 vuotta sitten
vanhempi
sitoutus
f422ff43ce

+ 35 - 10
Tiltfile

@@ -32,17 +32,24 @@ if (cluster.startswith("kind-")):
     updated_install = encode_yaml_stream(decoded)
 
     k8s_yaml(updated_install)
-
-    k8s_resource(
-        workload='porter-server-web',
-        port_forwards="8080:8080",
-        labels=["porter"],
-        resource_deps=["porter-binary"],
-    )
 else:
     local("echo 'Be careful that you aren't connected to a staging or prod cluster' && exit 1")
     exit()
 
+k8s_resource(
+    workload='porter-server-web',
+    port_forwards=["8080:8080"],
+    labels=["porter"],
+    resource_deps=["porter-binary"],
+)
+
+k8s_resource(
+    workload='porter-auth-web',
+    port_forwards=["8090:8090"],
+    labels=["porter"],
+    resource_deps=["porter-binary"],
+)
+
 watch_file('zarf/helm/.server.env')
 watch_file('zarf/helm/.dashboard.env')
 
@@ -76,13 +83,17 @@ local_resource(
     cmd='tilt disable porter-server-web-test-connection',
     resource_deps=["porter-server-web"]
 )
+local_resource(
+    name="disable-auth-helm-test",
+    cmd='tilt disable porter-auth-web-test-connection',
+    resource_deps=["porter-auth-web"]
+)
 
 docker_build_with_restart(
     ref="porter1/porter-server",
     context=".",
     dockerfile="zarf/docker/Dockerfile.server.tilt",
-    # entrypoint='dlv --listen=:40000 --api-version=2 --headless=true --log=true exec /porter/bin/app',
-    entrypoint='/app/migrate && /app/porter --auth',
+    entrypoint='/app/migrate && /app/porter',
     build_args={},
     only=[
         "bin",
@@ -91,7 +102,21 @@ docker_build_with_restart(
         sync('./bin/porter', '/app/'),
         sync('./bin/migrate', '/app/'),
     ]
-) 
+)
+
+docker_build_with_restart(
+    ref="porter1/porter-auth",
+    context=".",
+    dockerfile="zarf/docker/Dockerfile.server.tilt",
+    entrypoint='/app/porter --auth',
+    build_args={},
+    only=[
+        "bin",
+    ],
+    live_update=[
+        sync('./bin/porter', '/app/'),
+    ]
+)
 
 local_resource(
   name='reload-server-config',

+ 96 - 3
api/authmanagement/api_token.go

@@ -2,13 +2,106 @@ package authmanagement
 
 import (
 	"context"
-	"errors"
+	"fmt"
+	"time"
+
+	"github.com/google/uuid"
+
+	"github.com/dgrijalva/jwt-go"
+
+	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/models"
+
+	"github.com/porter-dev/porter/internal/telemetry"
 
 	"github.com/bufbuild/connect-go"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 )
 
-// APIToken returns an encoded token for programmatic access to the Porter UI
+// APIToken returns an encoded token for programmatic access to the Porter UI. Currently, this token is hardcoded
+// to use the "porter-agent-token" name. Once this endpoint is used for multiple tokens, the GRPC request should
+// include the token name or type as an argument.
 func (a AuthManagementService) APIToken(ctx context.Context, req *connect.Request[porterv1.APITokenRequest]) (*connect.Response[porterv1.APITokenResponse], error) {
-	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("porter.v1.AuthManagementService.APIToken is not implemented"))
+	ctx, span := telemetry.NewSpan(ctx, "auth-endpoint-api-token")
+	defer span.End()
+
+	resp := connect.NewResponse(&porterv1.APITokenResponse{})
+
+	if req == nil {
+		err := telemetry.Error(ctx, span, nil, "missing request")
+		return resp, connect.NewError(connect.CodeInvalidArgument, err)
+	}
+	if req.Msg == nil {
+		err := telemetry.Error(ctx, span, nil, "missing request message")
+		return resp, connect.NewError(connect.CodeInvalidArgument, err)
+	}
+	if req.Msg.ProjectId == 0 {
+		err := telemetry.Error(ctx, span, nil, "missing project id")
+		return resp, connect.NewError(connect.CodeInvalidArgument, err)
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "project-id", Value: req.Msg.ProjectId})
+
+	existingTokens, err := a.Config.APITokenManager.ListAPITokensByProjectID(uint(req.Msg.ProjectId))
+	if err != nil {
+		return resp, telemetry.Error(ctx, span, err, "error listing api tokens")
+	}
+
+	var apiToken *models.APIToken
+	for _, tok := range existingTokens {
+		if tok.Name == "porter-agent-token" {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "token-exists", Value: true})
+			apiToken = tok
+		}
+	}
+
+	if apiToken == nil {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "token-exists", Value: false})
+
+		tokenID, err := uuid.NewUUID()
+		if err != nil {
+			return resp, telemetry.Error(ctx, span, err, "error generating tokenID")
+		}
+
+		expiresAt := time.Now().Add(time.Hour * 24 * 365)
+
+		apiToken = &models.APIToken{
+			UniqueID:   tokenID.String(),
+			ProjectID:  uint(req.Msg.ProjectId),
+			Expiry:     &expiresAt,
+			Revoked:    false,
+			PolicyUID:  "developer",
+			PolicyName: "developer",
+			Name:       "porter-agent-token",
+		}
+
+		apiToken, err = a.Config.APITokenManager.CreateAPIToken(apiToken)
+		if err != nil {
+			return resp, telemetry.Error(ctx, span, err, "error creating api token")
+		}
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "token-id", Value: apiToken.UniqueID},
+		telemetry.AttributeKV{Key: "expiry", Value: apiToken.Expiry.UTC().String()},
+	)
+
+	now := time.Now().UTC()
+
+	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
+		"sub_kind":   "porter-agent",
+		"sub":        string(token.API),
+		"iat":        fmt.Sprintf("%d", now.Unix()),
+		"project_id": apiToken.ProjectID,
+		"token_id":   apiToken.UniqueID,
+	})
+
+	encodedToken, err := token.SignedString([]byte(a.Config.TokenGeneratorSecret))
+	if err != nil {
+		return resp, telemetry.Error(ctx, span, err, "error signing token")
+	}
+
+	resp.Msg.Token = encodedToken
+
+	return resp, nil
 }

+ 68 - 7
api/authmanagement/server.go

@@ -6,6 +6,17 @@ import (
 	"net/http"
 	"time"
 
+	grpcreflect "github.com/bufbuild/connect-grpcreflect-go"
+
+	"github.com/porter-dev/porter/ee/integrations/vault"
+	"github.com/porter-dev/porter/internal/repository/credentials"
+	"github.com/porter-dev/porter/internal/repository/gorm"
+
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/internal/adapter"
+
+	"github.com/porter-dev/porter/internal/repository"
+
 	"github.com/joeshaw/envdecode"
 
 	"github.com/bufbuild/connect-go"
@@ -15,14 +26,32 @@ import (
 	"golang.org/x/net/http2/h2c"
 )
 
-// Config contains all configuration options for the AuthManagementService
-type Config struct {
+// ServiceEnv contains all environment variables for the AuthManagementService
+type ServiceEnv struct {
 	// Port is the port that the AuthManagementService listens on
 	Port int `env:"AUTH_MANAGEMENT_SERVICE_PORT,default=8090"`
 	// TokenGeneratorSecret is the secret used to generate JWT tokens
 	TokenGeneratorSecret string `env:"TOKEN_GENERATOR_SECRET,default=secret"`
 }
 
+// EnvVars holds all the environment variables to be decoded for the AuthManagementService
+type EnvVars struct {
+	// DBEnv holds all the environment variables for DB connection
+	DBEnv env.DBConf
+	// ServiceEnv holds all the environment variables specific to the AuthManagementService
+	ServiceEnv ServiceEnv
+}
+
+// Config contains all configuration options for the AuthManagementService
+type Config struct {
+	// Port is the port that the AuthManagementService listens on
+	Port int
+	// TokenGeneratorSecret is the secret used to generate JWT tokens
+	TokenGeneratorSecret string
+	// APITokenManager is the interface for managing API tokens
+	APITokenManager repository.APITokenRepository
+}
+
 // AuthManagementService stores the service config and implements the gRPC server's interface
 type AuthManagementService struct {
 	Config Config
@@ -32,23 +61,55 @@ type AuthManagementService struct {
 func NewService() (AuthManagementService, error) {
 	var server AuthManagementService
 
-	var config Config
-	if err := envdecode.StrictDecode(&config); err != nil {
-		return server, fmt.Errorf("Failed to decode server conf: %s", err)
+	var envVars EnvVars
+	if err := envdecode.StrictDecode(&envVars); err != nil {
+		return server, fmt.Errorf("failed to decode environment variables: %w", err)
+	}
+
+	db, err := adapter.New(&envVars.DBEnv)
+	if err != nil {
+		return server, fmt.Errorf("failed to create DB client: %w", err)
+	}
+
+	var instanceCredentialBackend credentials.CredentialStorage
+	if envVars.DBEnv.VaultEnabled {
+		instanceCredentialBackend = vault.NewClient(
+			envVars.DBEnv.VaultServerURL,
+			envVars.DBEnv.VaultAPIKey,
+			envVars.DBEnv.VaultPrefix,
+		)
 	}
 
-	server.Config = config
+	var key [32]byte
+
+	for i, b := range []byte(envVars.DBEnv.EncryptionKey) {
+		key[i] = b
+	}
+
+	repo := gorm.NewRepository(db, &key, instanceCredentialBackend)
+
+	server.Config = Config{
+		Port:                 envVars.ServiceEnv.Port,
+		TokenGeneratorSecret: envVars.ServiceEnv.TokenGeneratorSecret,
+		APITokenManager:      repo.APIToken(),
+	}
 
 	return server, nil
 }
 
-// ListenAndServe starts the AuthManagementService
+// ListenAndServe starts the AuthManagementService and will shutdown when the context is canceled
 func (a AuthManagementService) ListenAndServe(ctx context.Context) error {
 	ctx, cancel := context.WithCancel(ctx)
 	defer cancel()
 
 	mux := http.NewServeMux()
 
+	reflector := grpcreflect.NewStaticReflector(
+		"porter.v1.AuthManagementService",
+	)
+	mux.Handle(grpcreflect.NewHandlerV1(reflector))
+	mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector))
+
 	mux.Handle(porterv1connect.NewAuthManagementServiceHandler(a,
 		connect.WithInterceptors(
 			otelconnect.NewInterceptor(otelconnect.WithTrustRemote()),

+ 3 - 3
api/server/authn/handler.go

@@ -142,8 +142,8 @@ func (authn *AuthN) handleForbiddenForSession(
 }
 
 func (authn *AuthN) verifyTokenWithNext(w http.ResponseWriter, r *http.Request, tok *token.Token) {
-	// if the token has a stored token id and secret we check that the token is valid in the database
-	if tok.Secret != "" && tok.TokenID != "" {
+	// if the token has a stored token id we check that the token is valid in the database
+	if tok.TokenID != "" {
 		apiToken, err := authn.config.Repo.APIToken().ReadAPIToken(tok.ProjectID, tok.TokenID)
 		if err != nil {
 			authn.sendForbiddenError(fmt.Errorf("token with id %s not valid", tok.TokenID), w, r)
@@ -224,7 +224,7 @@ func (authn *AuthN) getTokenFromRequest(r *http.Request) (*token.Token, error) {
 
 	tok, err := token.GetTokenFromEncoded(reqToken, authn.config.TokenConf)
 	if err != nil {
-		return nil, errInvalidToken
+		return nil, fmt.Errorf("%s: %w", errInvalidToken.Error(), err)
 	}
 
 	return tok, nil

+ 21 - 21
cmd/app/main.go

@@ -14,6 +14,7 @@ import (
 	"syscall"
 
 	"github.com/porter-dev/porter/api/server"
+	"github.com/porter-dev/porter/api/server/router"
 
 	"github.com/porter-dev/porter/api/authmanagement"
 
@@ -21,7 +22,6 @@ import (
 
 	"github.com/porter-dev/porter/internal/telemetry"
 
-	"github.com/porter-dev/porter/api/server/router"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config/loader"
 	"github.com/porter-dev/porter/internal/models"
@@ -38,7 +38,7 @@ func main() {
 
 	var versionFlag, authServiceFlag bool
 	flag.BoolVar(&versionFlag, "version", false, "print version and exit")
-	flag.BoolVar(&authServiceFlag, "auth", false, "run auth service in addition to porter api")
+	flag.BoolVar(&authServiceFlag, "auth", false, "run auth service instead of porter api")
 	flag.Parse()
 
 	// Exit safely when version is used
@@ -67,25 +67,6 @@ func main() {
 	}
 	defer tracer.Shutdown()
 
-	config.Logger.Info().Msg("Creating API router")
-	appRouter := router.NewAPIRouter(config)
-	config.Logger.Info().Msg("Created API router")
-
-	p := server.PorterAPIServer{
-		Port:       config.ServerConf.Port,
-		Router:     appRouter,
-		ServerConf: config.ServerConf,
-	}
-
-	g.Go(func() error {
-		config.Logger.Info().Msgf("Starting PorterAPI server on port %d", config.ServerConf.Port)
-		if err := p.ListenAndServe(ctx); err != nil && err != http.ErrServerClosed {
-			return fmt.Errorf("PorterAPI server failed: %s", err.Error())
-		}
-		config.Logger.Info().Msg("Shutting down PorterAPI server")
-		return nil
-	})
-
 	if authServiceFlag {
 		g.Go(func() error {
 			a, err := authmanagement.NewService()
@@ -100,6 +81,25 @@ func main() {
 			config.Logger.Info().Msg("Shutting down AuthManagement server")
 			return nil
 		})
+	} else {
+		config.Logger.Info().Msg("Creating API router")
+		appRouter := router.NewAPIRouter(config)
+		config.Logger.Info().Msg("Created API router")
+
+		p := server.PorterAPIServer{
+			Port:       config.ServerConf.Port,
+			Router:     appRouter,
+			ServerConf: config.ServerConf,
+		}
+
+		g.Go(func() error {
+			config.Logger.Info().Msgf("Starting PorterAPI server on port %d", config.ServerConf.Port)
+			if err := p.ListenAndServe(ctx); err != nil && err != http.ErrServerClosed {
+				return fmt.Errorf("PorterAPI server failed: %s", err.Error())
+			}
+			config.Logger.Info().Msg("Shutting down PorterAPI server")
+			return nil
+		})
 	}
 
 	termFunc := func() error {

+ 3 - 2
go.mod

@@ -52,7 +52,7 @@ require (
 	google.golang.org/api v0.103.0
 	google.golang.org/genproto v0.0.0-20221202195650-67e5cbc046fd
 	google.golang.org/grpc v1.52.0
-	google.golang.org/protobuf v1.29.0
+	google.golang.org/protobuf v1.30.0
 	gorm.io/gorm v1.24.2
 	k8s.io/api v0.26.0
 	k8s.io/apimachinery v0.26.0
@@ -70,7 +70,8 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v0.23.1
 	github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v0.5.0
 	github.com/briandowns/spinner v1.18.1
-	github.com/bufbuild/connect-go v1.5.2
+	github.com/bufbuild/connect-go v1.7.0
+	github.com/bufbuild/connect-grpcreflect-go v1.1.0
 	github.com/bufbuild/connect-opentelemetry-go v0.1.0
 	github.com/glebarez/sqlite v1.6.0
 	github.com/go-chi/chi/v5 v5.0.8

+ 6 - 4
go.sum

@@ -299,8 +299,10 @@ github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gL
 github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
 github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
 github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
-github.com/bufbuild/connect-go v1.5.2 h1:G4EZd5gF1U1ZhhbVJXplbuUnfKpBZ5j5izqIwu2g2W8=
-github.com/bufbuild/connect-go v1.5.2/go.mod h1:GmMJYR6orFqD0Y6ZgX8pwQ8j9baizDrIQMm1/a6LnHk=
+github.com/bufbuild/connect-go v1.7.0 h1:MGp82v7SCza+3RhsVhV7aMikwxvI3ZfD72YiGt8FYJo=
+github.com/bufbuild/connect-go v1.7.0/go.mod h1:GmMJYR6orFqD0Y6ZgX8pwQ8j9baizDrIQMm1/a6LnHk=
+github.com/bufbuild/connect-grpcreflect-go v1.1.0 h1:T0FKu1y9zZW4cjHuF+Q7jIN6ek8HTpCxOP8ZsORZICg=
+github.com/bufbuild/connect-grpcreflect-go v1.1.0/go.mod h1:AxcY2fSAr+oQQuu+K35qy2VDtX+LWr7SrS2SvfjY898=
 github.com/bufbuild/connect-opentelemetry-go v0.1.0 h1:UA3mWNBDpie9iObZCB/RTUbHp/J8tSg3rwA6B38U2Cg=
 github.com/bufbuild/connect-opentelemetry-go v0.1.0/go.mod h1:D/pbarAjos2MS7DVhwCJksyOtoEmfBbfQasta2aJkKI=
 github.com/buger/jsonparser v0.0.0-20180808090653-f4dd9f5a6b44/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
@@ -2548,8 +2550,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.29.0 h1:44S3JjaKmLEE4YIkjzexaP+NzZsudE3Zin5Njn/pYX0=
-google.golang.org/protobuf v1.29.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
+google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

+ 6 - 3
internal/auth/token/token.go

@@ -108,9 +108,12 @@ func GetTokenFromEncoded(tokenString string, conf *TokenGeneratorConf) (*Token,
 	}
 
 	if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
-		iby, err := strconv.ParseUint(fmt.Sprintf("%v", claims["iby"]), 10, 64)
-		if err != nil {
-			return nil, fmt.Errorf("invalid iby claim: %v", err)
+		var iby uint64
+		if _, ok := claims["iby"]; ok {
+			iby, err = strconv.ParseUint(fmt.Sprintf("%v", claims["iby"]), 10, 64)
+			if err != nil {
+				return nil, fmt.Errorf("invalid iby claim: %v", err)
+			}
 		}
 
 		projID, err := strconv.ParseUint(fmt.Sprintf("%v", claims["project_id"]), 10, 64)

+ 104 - 0
zarf/helm/auth.yaml

@@ -0,0 +1,104 @@
+# Default values for docker-template.
+# This is a YAML-formatted file.
+# Declare variables to be passed into your templates.
+
+auto_deploy: true
+
+replicaCount: 1
+
+terminationGracePeriodSeconds: 30
+
+image:
+  repository: porter1/porter-auth
+  pullPolicy: Always
+  # Overrides the image tag whose default is the chart appVersion.
+  tag: latest
+
+service:
+  port: 8090
+
+ingress:
+  enabled: false
+  hosts: []
+  porter_hosts: []
+  provider: aws
+  custom_domain: false
+  custom_paths: []
+  rewriteCustomPathsEnabled: true
+  annotations: {}
+  wildcard: false
+  tls: true
+  useDefaultIngressTLSSecret: false
+
+container:
+  port: 8090
+  command: porter --auth
+  args: ""
+  env:
+    normal:
+  lifecycle:
+    postStart:
+    preStop:
+
+resources:
+  requests:
+    cpu: 500m
+    memory: 512Mi
+
+health:
+  livenessProbe:
+    enabled: false
+    path: "/livez"
+    scheme: "HTTP"
+    initialDelaySeconds: 0
+    periodSeconds: 5
+    timeoutSeconds: 1
+    successThreshold: 1
+    failureThreshold: 3
+    auth:
+      enabled: false
+      username: ""
+      password: ""
+
+  livenessCommand:
+    enabled: false
+    command: "ls -l"
+    initialDelaySeconds: 5
+    periodSeconds: 5
+    timeoutSeconds: 1
+    successThreshold: 1
+    failureThreshold: 3
+
+  readinessProbe:
+    enabled: false
+    path: "/readyz"
+    scheme: "HTTP"
+    initialDelaySeconds: 0
+    periodSeconds: 5
+    timeoutSeconds: 1
+    successThreshold: 1
+    failureThreshold: 3
+    auth:
+      enabled: false
+      username: ""
+      password: ""
+
+  startupProbe:
+    enabled: false
+    path: "/startupz"
+    scheme: "HTTP"
+    failureThreshold: 3
+    periodSeconds: 5
+    timeoutSeconds: 1
+    auth:
+      enabled: false
+      username: ""
+      password: ""
+      
+emptyDir:
+  enabled: false
+  mountPath: /local
+
+podSecurityContext: 
+  runAsNonRoot: false
+  runAsUser: 0

+ 24 - 0
zarf/helm/kustomization.yaml

@@ -6,6 +6,10 @@ helmCharts:
   repo: https://charts.getporter.dev
   releaseName: porter-server
   valuesFile: server.yaml
+- name: web
+  repo: https://charts.getporter.dev
+  releaseName: porter-auth
+  valuesFile: auth.yaml
 
 configMapGenerator:
 - name: porter-server-env
@@ -34,3 +38,23 @@ patchesStrategicMerge:
         - name: ssh-keys
           hostPath:
             path: /local-user/.ssh
+- |-
+  apiVersion: apps/v1
+  kind: Deployment
+  metadata:
+    name: porter-auth-web
+  spec:
+    template:
+      spec:
+        containers:
+          - name: web
+            envFrom:
+              - configMapRef:
+                  name: porter-server-env
+            volumeMounts:
+              - mountPath: /app/ssh
+                name: ssh-keys
+        volumes:
+          - name: ssh-keys
+            hostPath:
+              path: /local-user/.ssh