[ARVADOS] created: 1.3.0-2344-ge977431a3

Git user git at public.arvados.org
Thu Mar 12 20:11:10 UTC 2020


        at  e977431a341020ab7dd7e4238786fa7dea5c9538 (commit)


commit e977431a341020ab7dd7e4238786fa7dea5c9538
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Thu Mar 12 16:11:05 2020 -0400

    16212: Support username/password authentication via PAM.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/build/run-tests.sh b/build/run-tests.sh
index 7328fad45..106793f7a 100755
--- a/build/run-tests.sh
+++ b/build/run-tests.sh
@@ -262,6 +262,9 @@ sanity_checks() {
     echo -n 'libpq libpq-fe.h: '
     find /usr/include -path '*/postgresql/libpq-fe.h' | egrep --max-count=1 . \
         || fatal "No libpq libpq-fe.h. Try: apt-get install libpq-dev"
+    echo -n 'libpam pam_appl.h: '
+    find /usr/include -path '*/security/pam_appl.h' | egrep --max-count=1 . \
+        || fatal "No libpam pam_appl.h. Try: apt-get install libpam-dev"
     echo -n 'postgresql: '
     psql --version || fatal "No postgresql. Try: apt-get install postgresql postgresql-client-common"
     echo -n 'phantomjs: '
diff --git a/go.mod b/go.mod
index 2cc5e89eb..4491b3598 100644
--- a/go.mod
+++ b/go.mod
@@ -36,6 +36,7 @@ require (
 	github.com/lib/pq v1.3.0
 	github.com/marstr/guid v1.1.1-0.20170427235115-8bdf7d1a087c // indirect
 	github.com/mitchellh/go-homedir v0.0.0-20161203194507-b8bc1bf76747 // indirect
+	github.com/msteinert/pam v0.0.0-20190215180659-f29b9f28d6f9
 	github.com/opencontainers/go-digest v1.0.0-rc1 // indirect
 	github.com/opencontainers/image-spec v1.0.1-0.20171125024018-577479e4dc27 // indirect
 	github.com/pelletier/go-buffruneio v0.2.0 // indirect
@@ -52,7 +53,7 @@ require (
 	golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550
 	golang.org/x/net v0.0.0-20190620200207-3b0461eec859
 	golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
-	golang.org/x/sys v0.0.0-20191105231009-c1f44814a5cd // indirect
+	golang.org/x/sys v0.0.0-20191105231009-c1f44814a5cd
 	google.golang.org/api v0.13.0
 	gopkg.in/check.v1 v1.0.0-20161208181325-20d25e280405
 	gopkg.in/square/go-jose.v2 v2.3.1
diff --git a/go.sum b/go.sum
index c3904fe84..18cf89b0e 100644
--- a/go.sum
+++ b/go.sum
@@ -123,6 +123,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/msteinert/pam v0.0.0-20190215180659-f29b9f28d6f9 h1:ZivaaKmjs9q90zi6I4gTLW6tbVGtlBjellr3hMYaly0=
+github.com/msteinert/pam v0.0.0-20190215180659-f29b9f28d6f9/go.mod h1:np1wUFZ6tyoke22qDJZY40URn9Ae51gX7ljIWXN5TJs=
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ=
 github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
diff --git a/lib/config/config.default.yml b/lib/config/config.default.yml
index 411296cbe..d8d254170 100644
--- a/lib/config/config.default.yml
+++ b/lib/config/config.default.yml
@@ -541,6 +541,28 @@ Clusters:
       # work. If false, only the primary email address will be used.
       GoogleAlternateEmailAddresses: true
 
+      # Use PAM to authenticate logins, using the specified PAM
+      # service name.
+      #
+      # Cannot be used in combination with OAuth2 (ProviderAppID) or
+      # Google (GoogleClientID).
+      PAM: false
+      PAMService: arvados
+
+      # Domain name (e.g., "example.com") to use to construct the
+      # user's email address if PAM authentication returns a username
+      # with no "@". If empty, use the PAM username as the user's
+      # email address, whether or not it contains "@".
+      #
+      # Note that the email address is used as the primary key for
+      # user records when logging in. Therefore, if you change
+      # PAMDefaultEmailDomain after the initial installation, you
+      # should also update existing user records to reflect the new
+      # domain. Otherwise, next time those users log in, they will be
+      # given new accounts instead of accessing their existing
+      # accounts.
+      PAMDefaultEmailDomain: ""
+
       # The cluster ID to delegate the user database.  When set,
       # logins on this cluster will be redirected to the login cluster
       # (login cluster must appear in RemoteClusters with Proxy: true)
diff --git a/lib/config/export.go b/lib/config/export.go
index 5973a16a1..ded03fc30 100644
--- a/lib/config/export.go
+++ b/lib/config/export.go
@@ -134,6 +134,9 @@ var whitelist = map[string]bool{
 	"Login.GoogleClientID":                         false,
 	"Login.GoogleClientSecret":                     false,
 	"Login.GoogleAlternateEmailAddresses":          false,
+	"Login.PAM":                                    true,
+	"Login.PAMService":                             false,
+	"Login.PAMDefaultEmailDomain":                  false,
 	"Login.ProviderAppID":                          false,
 	"Login.ProviderAppSecret":                      false,
 	"Login.LoginCluster":                           true,
diff --git a/lib/config/generated_config.go b/lib/config/generated_config.go
index f40093a96..368569103 100644
--- a/lib/config/generated_config.go
+++ b/lib/config/generated_config.go
@@ -547,6 +547,28 @@ Clusters:
       # work. If false, only the primary email address will be used.
       GoogleAlternateEmailAddresses: true
 
+      # Use PAM to authenticate logins, using the specified PAM
+      # service name.
+      #
+      # Cannot be used in combination with OAuth2 (ProviderAppID) or
+      # Google (GoogleClientID).
+      PAM: false
+      PAMService: arvados
+
+      # Domain name (e.g., "example.com") to use to construct the
+      # user's email address if PAM authentication returns a username
+      # with no "@". If empty, use the PAM username as the user's
+      # email address, whether or not it contains "@".
+      #
+      # Note that the email address is used as the primary key for
+      # user records when logging in. Therefore, if you change
+      # PAMDefaultEmailDomain after the initial installation, you
+      # should also update existing user records to reflect the new
+      # domain. Otherwise, next time those users log in, they will be
+      # given new accounts instead of accessing their existing
+      # accounts.
+      PAMDefaultEmailDomain: ""
+
       # The cluster ID to delegate the user database.  When set,
       # logins on this cluster will be redirected to the login cluster
       # (login cluster must appear in RemoteClusters with Proxy: true)
diff --git a/lib/controller/localdb/conn.go b/lib/controller/localdb/conn.go
index ac092382d..909b6e1ff 100644
--- a/lib/controller/localdb/conn.go
+++ b/lib/controller/localdb/conn.go
@@ -6,7 +6,6 @@ package localdb
 
 import (
 	"context"
-	"errors"
 
 	"git.arvados.org/arvados.git/lib/controller/railsproxy"
 	"git.arvados.org/arvados.git/lib/controller/rpc"
@@ -18,35 +17,22 @@ type railsProxy = rpc.Conn
 type Conn struct {
 	cluster     *arvados.Cluster
 	*railsProxy // handles API methods that aren't defined on Conn itself
-
-	googleLoginController
+	loginController
 }
 
 func NewConn(cluster *arvados.Cluster) *Conn {
+	railsProxy := railsproxy.NewConn(cluster)
 	return &Conn{
-		cluster:    cluster,
-		railsProxy: railsproxy.NewConn(cluster),
+		cluster:         cluster,
+		railsProxy:      railsProxy,
+		loginController: chooseLoginController(cluster, railsProxy),
 	}
 }
 
 func (conn *Conn) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
-	if conn.cluster.Login.ProviderAppID != "" {
-		// Proxy to RailsAPI, which hands off to sso-provider.
-		return conn.railsProxy.Logout(ctx, opts)
-	} else {
-		return conn.googleLoginController.Logout(ctx, conn.cluster, conn.railsProxy, opts)
-	}
+	return conn.loginController.Logout(ctx, opts)
 }
 
 func (conn *Conn) Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
-	wantGoogle := conn.cluster.Login.GoogleClientID != ""
-	wantSSO := conn.cluster.Login.ProviderAppID != ""
-	if wantGoogle == wantSSO {
-		return arvados.LoginResponse{}, errors.New("configuration problem: exactly one of Login.GoogleClientID and Login.ProviderAppID must be configured")
-	} else if wantGoogle {
-		return conn.googleLoginController.Login(ctx, conn.cluster, conn.railsProxy, opts)
-	} else {
-		// Proxy to RailsAPI, which hands off to sso-provider.
-		return conn.railsProxy.Login(ctx, opts)
-	}
+	return conn.loginController.Login(ctx, opts)
 }
diff --git a/lib/controller/localdb/login.go b/lib/controller/localdb/login.go
index 2e50b84f4..af9a03482 100644
--- a/lib/controller/localdb/login.go
+++ b/lib/controller/localdb/login.go
@@ -5,54 +5,45 @@
 package localdb
 
 import (
-	"bytes"
 	"context"
-	"crypto/hmac"
-	"crypto/sha256"
-	"encoding/base64"
 	"errors"
-	"fmt"
-	"net/url"
-	"strings"
-	"sync"
-	"text/template"
-	"time"
 
-	"git.arvados.org/arvados.git/lib/controller/rpc"
 	"git.arvados.org/arvados.git/sdk/go/arvados"
-	"git.arvados.org/arvados.git/sdk/go/auth"
-	"git.arvados.org/arvados.git/sdk/go/ctxlog"
-	"github.com/coreos/go-oidc"
-	"golang.org/x/oauth2"
-	"google.golang.org/api/option"
-	"google.golang.org/api/people/v1"
 )
 
-type googleLoginController struct {
-	issuer            string // override OIDC issuer URL (normally https://accounts.google.com) for testing
-	peopleAPIBasePath string // override Google People API base URL (normally set by google pkg to https://people.googleapis.com/)
-	provider          *oidc.Provider
-	mu                sync.Mutex
+type loginController interface {
+	Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error)
+	Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error)
 }
 
-func (ctrl *googleLoginController) getProvider() (*oidc.Provider, error) {
-	ctrl.mu.Lock()
-	defer ctrl.mu.Unlock()
-	if ctrl.provider == nil {
-		issuer := ctrl.issuer
-		if issuer == "" {
-			issuer = "https://accounts.google.com"
+func chooseLoginController(cluster *arvados.Cluster, railsProxy *railsProxy) loginController {
+	wantGoogle := cluster.Login.GoogleClientID != ""
+	wantSSO := cluster.Login.ProviderAppID != ""
+	wantPAM := cluster.Login.PAM
+	switch {
+	case wantGoogle && !wantSSO && !wantPAM:
+		return &googleLoginController{Cluster: cluster, RailsProxy: railsProxy}
+	case !wantGoogle && wantSSO && !wantPAM:
+		return railsProxy
+	case !wantGoogle && !wantSSO && wantPAM:
+		return &pamLoginController{Cluster: cluster, RailsProxy: railsProxy}
+	default:
+		return errorLoginController{
+			error: errors.New("configuration problem: exactly one of Login.GoogleClientID, Login.ProviderAppID, or Login.PAM must be configured"),
 		}
-		provider, err := oidc.NewProvider(context.Background(), issuer)
-		if err != nil {
-			return nil, err
-		}
-		ctrl.provider = provider
 	}
-	return ctrl.provider, nil
 }
 
-func (ctrl *googleLoginController) Logout(ctx context.Context, cluster *arvados.Cluster, railsproxy *railsProxy, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
+type errorLoginController struct{ error }
+
+func (ctrl errorLoginController) Login(context.Context, arvados.LoginOptions) (arvados.LoginResponse, error) {
+	return arvados.LoginResponse{}, ctrl.error
+}
+func (ctrl errorLoginController) Logout(context.Context, arvados.LogoutOptions) (arvados.LogoutResponse, error) {
+	return arvados.LogoutResponse{}, ctrl.error
+}
+
+func noopLogout(cluster *arvados.Cluster, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
 	target := opts.ReturnTo
 	if target == "" {
 		if cluster.Services.Workbench2.ExternalURL.Host != "" {
@@ -63,228 +54,3 @@ func (ctrl *googleLoginController) Logout(ctx context.Context, cluster *arvados.
 	}
 	return arvados.LogoutResponse{RedirectLocation: target}, nil
 }
-
-func (ctrl *googleLoginController) Login(ctx context.Context, cluster *arvados.Cluster, railsproxy *railsProxy, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
-	provider, err := ctrl.getProvider()
-	if err != nil {
-		return ctrl.loginError(fmt.Errorf("error setting up OpenID Connect provider: %s", err))
-	}
-	redirURL, err := (*url.URL)(&cluster.Services.Controller.ExternalURL).Parse("/login")
-	if err != nil {
-		return ctrl.loginError(fmt.Errorf("error making redirect URL: %s", err))
-	}
-	conf := &oauth2.Config{
-		ClientID:     cluster.Login.GoogleClientID,
-		ClientSecret: cluster.Login.GoogleClientSecret,
-		Endpoint:     provider.Endpoint(),
-		Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
-		RedirectURL:  redirURL.String(),
-	}
-	verifier := provider.Verifier(&oidc.Config{
-		ClientID: conf.ClientID,
-	})
-	if opts.State == "" {
-		// Initiate Google sign-in.
-		if opts.ReturnTo == "" {
-			return ctrl.loginError(errors.New("missing return_to parameter"))
-		}
-		me := url.URL(cluster.Services.Controller.ExternalURL)
-		callback, err := me.Parse("/" + arvados.EndpointLogin.Path)
-		if err != nil {
-			return ctrl.loginError(err)
-		}
-		conf.RedirectURL = callback.String()
-		state := ctrl.newOAuth2State([]byte(cluster.SystemRootToken), opts.Remote, opts.ReturnTo)
-		return arvados.LoginResponse{
-			RedirectLocation: conf.AuthCodeURL(state.String(),
-				// prompt=select_account tells Google
-				// to show the "choose which Google
-				// account" page, even if the client
-				// is currently logged in to exactly
-				// one Google account.
-				oauth2.SetAuthURLParam("prompt", "select_account")),
-		}, nil
-	} else {
-		// Callback after Google sign-in.
-		state := ctrl.parseOAuth2State(opts.State)
-		if !state.verify([]byte(cluster.SystemRootToken)) {
-			return ctrl.loginError(errors.New("invalid OAuth2 state"))
-		}
-		oauth2Token, err := conf.Exchange(ctx, opts.Code)
-		if err != nil {
-			return ctrl.loginError(fmt.Errorf("error in OAuth2 exchange: %s", err))
-		}
-		rawIDToken, ok := oauth2Token.Extra("id_token").(string)
-		if !ok {
-			return ctrl.loginError(errors.New("error in OAuth2 exchange: no ID token in OAuth2 token"))
-		}
-		idToken, err := verifier.Verify(ctx, rawIDToken)
-		if err != nil {
-			return ctrl.loginError(fmt.Errorf("error verifying ID token: %s", err))
-		}
-		authinfo, err := ctrl.getAuthInfo(ctx, cluster, conf, oauth2Token, idToken)
-		if err != nil {
-			return ctrl.loginError(err)
-		}
-		ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{cluster.SystemRootToken}})
-		return railsproxy.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
-			ReturnTo: state.Remote + "," + state.ReturnTo,
-			AuthInfo: *authinfo,
-		})
-	}
-}
-
-// Use a person's token to get all of their email addresses, with the
-// primary address at index 0. The provided defaultAddr is always
-// included in the returned slice, and is used as the primary if the
-// Google API does not indicate one.
-func (ctrl *googleLoginController) getAuthInfo(ctx context.Context, cluster *arvados.Cluster, conf *oauth2.Config, token *oauth2.Token, idToken *oidc.IDToken) (*rpc.UserSessionAuthInfo, error) {
-	var ret rpc.UserSessionAuthInfo
-	defer ctxlog.FromContext(ctx).WithField("ret", &ret).Debug("getAuthInfo returned")
-
-	var claims struct {
-		Name     string `json:"name"`
-		Email    string `json:"email"`
-		Verified bool   `json:"email_verified"`
-	}
-	if err := idToken.Claims(&claims); err != nil {
-		return nil, fmt.Errorf("error extracting claims from ID token: %s", err)
-	} else if claims.Verified {
-		// Fall back to this info if the People API call
-		// (below) doesn't return a primary && verified email.
-		if names := strings.Fields(strings.TrimSpace(claims.Name)); len(names) > 1 {
-			ret.FirstName = strings.Join(names[0:len(names)-1], " ")
-			ret.LastName = names[len(names)-1]
-		} else {
-			ret.FirstName = names[0]
-		}
-		ret.Email = claims.Email
-	}
-
-	if !cluster.Login.GoogleAlternateEmailAddresses {
-		if ret.Email == "" {
-			return nil, fmt.Errorf("cannot log in with unverified email address %q", claims.Email)
-		}
-		return &ret, nil
-	}
-
-	svc, err := people.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx, token)), option.WithScopes(people.UserEmailsReadScope))
-	if err != nil {
-		return nil, fmt.Errorf("error setting up People API: %s", err)
-	}
-	if p := ctrl.peopleAPIBasePath; p != "" {
-		// Override normal API endpoint (for testing)
-		svc.BasePath = p
-	}
-	person, err := people.NewPeopleService(svc).Get("people/me").PersonFields("emailAddresses,names").Do()
-	if err != nil {
-		if strings.Contains(err.Error(), "Error 403") && strings.Contains(err.Error(), "accessNotConfigured") {
-			// Log the original API error, but display
-			// only the "fix config" advice to the user.
-			ctxlog.FromContext(ctx).WithError(err).WithField("email", ret.Email).Error("People API is not enabled")
-			return nil, errors.New("configuration error: Login.GoogleAlternateEmailAddresses is true, but Google People API is not enabled")
-		} else {
-			return nil, fmt.Errorf("error getting profile info from People API: %s", err)
-		}
-	}
-
-	// The given/family names returned by the People API and
-	// flagged as "primary" (if any) take precedence over the
-	// split-by-whitespace result from above.
-	for _, name := range person.Names {
-		if name.Metadata != nil && name.Metadata.Primary {
-			ret.FirstName = name.GivenName
-			ret.LastName = name.FamilyName
-			break
-		}
-	}
-
-	altEmails := map[string]bool{}
-	if ret.Email != "" {
-		altEmails[ret.Email] = true
-	}
-	for _, ea := range person.EmailAddresses {
-		if ea.Metadata == nil || !ea.Metadata.Verified {
-			ctxlog.FromContext(ctx).WithField("address", ea.Value).Info("skipping unverified email address")
-			continue
-		}
-		altEmails[ea.Value] = true
-		if ea.Metadata.Primary || ret.Email == "" {
-			ret.Email = ea.Value
-		}
-	}
-	if len(altEmails) == 0 {
-		return nil, errors.New("cannot log in without a verified email address")
-	}
-	for ae := range altEmails {
-		if ae != ret.Email {
-			ret.AlternateEmails = append(ret.AlternateEmails, ae)
-			if i := strings.Index(ae, "@"); i > 0 && strings.ToLower(ae[i+1:]) == strings.ToLower(cluster.Users.PreferDomainForUsername) {
-				ret.Username = strings.SplitN(ae[:i], "+", 2)[0]
-			}
-		}
-	}
-	return &ret, nil
-}
-
-func (ctrl *googleLoginController) loginError(sendError error) (resp arvados.LoginResponse, err error) {
-	tmpl, err := template.New("error").Parse(`<h2>Login error:</h2><p>{{.}}</p>`)
-	if err != nil {
-		return
-	}
-	err = tmpl.Execute(&resp.HTML, sendError.Error())
-	return
-}
-
-func (ctrl *googleLoginController) newOAuth2State(key []byte, remote, returnTo string) oauth2State {
-	s := oauth2State{
-		Time:     time.Now().Unix(),
-		Remote:   remote,
-		ReturnTo: returnTo,
-	}
-	s.HMAC = s.computeHMAC(key)
-	return s
-}
-
-type oauth2State struct {
-	HMAC     []byte // hash of other fields; see computeHMAC()
-	Time     int64  // creation time (unix timestamp)
-	Remote   string // remote cluster if requesting a salted token, otherwise blank
-	ReturnTo string // redirect target
-}
-
-func (ctrl *googleLoginController) parseOAuth2State(encoded string) (s oauth2State) {
-	// Errors are not checked. If decoding/parsing fails, the
-	// token will be rejected by verify().
-	decoded, _ := base64.RawURLEncoding.DecodeString(encoded)
-	f := strings.Split(string(decoded), "\n")
-	if len(f) != 4 {
-		return
-	}
-	fmt.Sscanf(f[0], "%x", &s.HMAC)
-	fmt.Sscanf(f[1], "%x", &s.Time)
-	fmt.Sscanf(f[2], "%s", &s.Remote)
-	fmt.Sscanf(f[3], "%s", &s.ReturnTo)
-	return
-}
-
-func (s oauth2State) verify(key []byte) bool {
-	if delta := time.Now().Unix() - s.Time; delta < 0 || delta > 300 {
-		return false
-	}
-	return hmac.Equal(s.computeHMAC(key), s.HMAC)
-}
-
-func (s oauth2State) String() string {
-	var buf bytes.Buffer
-	enc := base64.NewEncoder(base64.RawURLEncoding, &buf)
-	fmt.Fprintf(enc, "%x\n%x\n%s\n%s", s.HMAC, s.Time, s.Remote, s.ReturnTo)
-	enc.Close()
-	return buf.String()
-}
-
-func (s oauth2State) computeHMAC(key []byte) []byte {
-	mac := hmac.New(sha256.New, key)
-	fmt.Fprintf(mac, "%x %s %s", s.Time, s.Remote, s.ReturnTo)
-	return mac.Sum(nil)
-}
diff --git a/lib/controller/localdb/login.go b/lib/controller/localdb/login_google.go
similarity index 79%
copy from lib/controller/localdb/login.go
copy to lib/controller/localdb/login_google.go
index 2e50b84f4..61bbaf01b 100644
--- a/lib/controller/localdb/login.go
+++ b/lib/controller/localdb/login_google.go
@@ -29,6 +29,9 @@ import (
 )
 
 type googleLoginController struct {
+	Cluster    *arvados.Cluster
+	RailsProxy *railsProxy
+
 	issuer            string // override OIDC issuer URL (normally https://accounts.google.com) for testing
 	peopleAPIBasePath string // override Google People API base URL (normally set by google pkg to https://people.googleapis.com/)
 	provider          *oidc.Provider
@@ -52,30 +55,22 @@ func (ctrl *googleLoginController) getProvider() (*oidc.Provider, error) {
 	return ctrl.provider, nil
 }
 
-func (ctrl *googleLoginController) Logout(ctx context.Context, cluster *arvados.Cluster, railsproxy *railsProxy, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
-	target := opts.ReturnTo
-	if target == "" {
-		if cluster.Services.Workbench2.ExternalURL.Host != "" {
-			target = cluster.Services.Workbench2.ExternalURL.String()
-		} else {
-			target = cluster.Services.Workbench1.ExternalURL.String()
-		}
-	}
-	return arvados.LogoutResponse{RedirectLocation: target}, nil
+func (ctrl *googleLoginController) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
+	return noopLogout(ctrl.Cluster, opts)
 }
 
-func (ctrl *googleLoginController) Login(ctx context.Context, cluster *arvados.Cluster, railsproxy *railsProxy, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
+func (ctrl *googleLoginController) Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
 	provider, err := ctrl.getProvider()
 	if err != nil {
-		return ctrl.loginError(fmt.Errorf("error setting up OpenID Connect provider: %s", err))
+		return loginError(fmt.Errorf("error setting up OpenID Connect provider: %s", err))
 	}
-	redirURL, err := (*url.URL)(&cluster.Services.Controller.ExternalURL).Parse("/login")
+	redirURL, err := (*url.URL)(&ctrl.Cluster.Services.Controller.ExternalURL).Parse("/login")
 	if err != nil {
-		return ctrl.loginError(fmt.Errorf("error making redirect URL: %s", err))
+		return loginError(fmt.Errorf("error making redirect URL: %s", err))
 	}
 	conf := &oauth2.Config{
-		ClientID:     cluster.Login.GoogleClientID,
-		ClientSecret: cluster.Login.GoogleClientSecret,
+		ClientID:     ctrl.Cluster.Login.GoogleClientID,
+		ClientSecret: ctrl.Cluster.Login.GoogleClientSecret,
 		Endpoint:     provider.Endpoint(),
 		Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
 		RedirectURL:  redirURL.String(),
@@ -86,15 +81,15 @@ func (ctrl *googleLoginController) Login(ctx context.Context, cluster *arvados.C
 	if opts.State == "" {
 		// Initiate Google sign-in.
 		if opts.ReturnTo == "" {
-			return ctrl.loginError(errors.New("missing return_to parameter"))
+			return loginError(errors.New("missing return_to parameter"))
 		}
-		me := url.URL(cluster.Services.Controller.ExternalURL)
+		me := url.URL(ctrl.Cluster.Services.Controller.ExternalURL)
 		callback, err := me.Parse("/" + arvados.EndpointLogin.Path)
 		if err != nil {
-			return ctrl.loginError(err)
+			return loginError(err)
 		}
 		conf.RedirectURL = callback.String()
-		state := ctrl.newOAuth2State([]byte(cluster.SystemRootToken), opts.Remote, opts.ReturnTo)
+		state := ctrl.newOAuth2State([]byte(ctrl.Cluster.SystemRootToken), opts.Remote, opts.ReturnTo)
 		return arvados.LoginResponse{
 			RedirectLocation: conf.AuthCodeURL(state.String(),
 				// prompt=select_account tells Google
@@ -107,27 +102,27 @@ func (ctrl *googleLoginController) Login(ctx context.Context, cluster *arvados.C
 	} else {
 		// Callback after Google sign-in.
 		state := ctrl.parseOAuth2State(opts.State)
-		if !state.verify([]byte(cluster.SystemRootToken)) {
-			return ctrl.loginError(errors.New("invalid OAuth2 state"))
+		if !state.verify([]byte(ctrl.Cluster.SystemRootToken)) {
+			return loginError(errors.New("invalid OAuth2 state"))
 		}
 		oauth2Token, err := conf.Exchange(ctx, opts.Code)
 		if err != nil {
-			return ctrl.loginError(fmt.Errorf("error in OAuth2 exchange: %s", err))
+			return loginError(fmt.Errorf("error in OAuth2 exchange: %s", err))
 		}
 		rawIDToken, ok := oauth2Token.Extra("id_token").(string)
 		if !ok {
-			return ctrl.loginError(errors.New("error in OAuth2 exchange: no ID token in OAuth2 token"))
+			return loginError(errors.New("error in OAuth2 exchange: no ID token in OAuth2 token"))
 		}
 		idToken, err := verifier.Verify(ctx, rawIDToken)
 		if err != nil {
-			return ctrl.loginError(fmt.Errorf("error verifying ID token: %s", err))
+			return loginError(fmt.Errorf("error verifying ID token: %s", err))
 		}
-		authinfo, err := ctrl.getAuthInfo(ctx, cluster, conf, oauth2Token, idToken)
+		authinfo, err := ctrl.getAuthInfo(ctx, ctrl.Cluster, conf, oauth2Token, idToken)
 		if err != nil {
-			return ctrl.loginError(err)
+			return loginError(err)
 		}
-		ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{cluster.SystemRootToken}})
-		return railsproxy.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
+		ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{ctrl.Cluster.SystemRootToken}})
+		return ctrl.RailsProxy.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
 			ReturnTo: state.Remote + "," + state.ReturnTo,
 			AuthInfo: *authinfo,
 		})
@@ -161,7 +156,7 @@ func (ctrl *googleLoginController) getAuthInfo(ctx context.Context, cluster *arv
 		ret.Email = claims.Email
 	}
 
-	if !cluster.Login.GoogleAlternateEmailAddresses {
+	if !ctrl.Cluster.Login.GoogleAlternateEmailAddresses {
 		if ret.Email == "" {
 			return nil, fmt.Errorf("cannot log in with unverified email address %q", claims.Email)
 		}
@@ -219,7 +214,7 @@ func (ctrl *googleLoginController) getAuthInfo(ctx context.Context, cluster *arv
 	for ae := range altEmails {
 		if ae != ret.Email {
 			ret.AlternateEmails = append(ret.AlternateEmails, ae)
-			if i := strings.Index(ae, "@"); i > 0 && strings.ToLower(ae[i+1:]) == strings.ToLower(cluster.Users.PreferDomainForUsername) {
+			if i := strings.Index(ae, "@"); i > 0 && strings.ToLower(ae[i+1:]) == strings.ToLower(ctrl.Cluster.Users.PreferDomainForUsername) {
 				ret.Username = strings.SplitN(ae[:i], "+", 2)[0]
 			}
 		}
@@ -227,7 +222,7 @@ func (ctrl *googleLoginController) getAuthInfo(ctx context.Context, cluster *arv
 	return &ret, nil
 }
 
-func (ctrl *googleLoginController) loginError(sendError error) (resp arvados.LoginResponse, err error) {
+func loginError(sendError error) (resp arvados.LoginResponse, err error) {
 	tmpl, err := template.New("error").Parse(`<h2>Login error:</h2><p>{{.}}</p>`)
 	if err != nil {
 		return
diff --git a/lib/controller/localdb/login_test.go b/lib/controller/localdb/login_google_test.go
similarity index 94%
rename from lib/controller/localdb/login_test.go
rename to lib/controller/localdb/login_google_test.go
index db6daa195..75fff3372 100644
--- a/lib/controller/localdb/login_test.go
+++ b/lib/controller/localdb/login_google_test.go
@@ -154,8 +154,8 @@ func (s *LoginSuite) SetUpTest(c *check.C) {
 	c.Assert(err, check.IsNil)
 
 	s.localdb = NewConn(s.cluster)
-	s.localdb.googleLoginController.issuer = s.fakeIssuer.URL
-	s.localdb.googleLoginController.peopleAPIBasePath = s.fakePeopleAPI.URL
+	s.localdb.loginController.(*googleLoginController).issuer = s.fakeIssuer.URL
+	s.localdb.loginController.(*googleLoginController).peopleAPIBasePath = s.fakePeopleAPI.URL
 
 	s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
 	s.localdb.railsProxy = rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
@@ -188,7 +188,7 @@ func (s *LoginSuite) TestGoogleLogin_Start(c *check.C) {
 		c.Check(target.Host, check.Equals, issuerURL.Host)
 		q := target.Query()
 		c.Check(q.Get("client_id"), check.Equals, "test%client$id")
-		state := s.localdb.googleLoginController.parseOAuth2State(q.Get("state"))
+		state := s.localdb.loginController.(*googleLoginController).parseOAuth2State(q.Get("state"))
 		c.Check(state.verify([]byte(s.cluster.SystemRootToken)), check.Equals, true)
 		c.Check(state.Time, check.Not(check.Equals), 0)
 		c.Check(state.Remote, check.Equals, remote)
@@ -223,7 +223,7 @@ func (s *LoginSuite) setupPeopleAPIError(c *check.C) {
 		w.WriteHeader(http.StatusForbidden)
 		fmt.Fprintln(w, `Error 403: accessNotConfigured`)
 	}))
-	s.localdb.googleLoginController.peopleAPIBasePath = s.fakePeopleAPI.URL
+	s.localdb.loginController.(*googleLoginController).peopleAPIBasePath = s.fakePeopleAPI.URL
 }
 
 func (s *LoginSuite) TestGoogleLogin_PeopleAPIDisabled(c *check.C) {
@@ -236,7 +236,7 @@ func (s *LoginSuite) TestGoogleLogin_PeopleAPIDisabled(c *check.C) {
 		State: state,
 	})
 	c.Check(err, check.IsNil)
-	authinfo := s.getCallbackAuthInfo(c)
+	authinfo := getCallbackAuthInfo(c, s.railsSpy)
 	c.Check(authinfo.Email, check.Equals, "joe.smith at primary.example.com")
 }
 
@@ -266,7 +266,7 @@ func (s *LoginSuite) TestGoogleLogin_Success(c *check.C) {
 	token := target.Query().Get("api_token")
 	c.Check(token, check.Matches, `v2/zzzzz-gj3su-.{15}/.{32,50}`)
 
-	authinfo := s.getCallbackAuthInfo(c)
+	authinfo := getCallbackAuthInfo(c, s.railsSpy)
 	c.Check(authinfo.FirstName, check.Equals, "Fake User")
 	c.Check(authinfo.LastName, check.Equals, "Name")
 	c.Check(authinfo.Email, check.Equals, "active-user at arvados.local")
@@ -312,7 +312,7 @@ func (s *LoginSuite) TestGoogleLogin_RealName(c *check.C) {
 		State: state,
 	})
 
-	authinfo := s.getCallbackAuthInfo(c)
+	authinfo := getCallbackAuthInfo(c, s.railsSpy)
 	c.Check(authinfo.FirstName, check.Equals, "Joseph")
 	c.Check(authinfo.LastName, check.Equals, "Psmith")
 }
@@ -326,7 +326,7 @@ func (s *LoginSuite) TestGoogleLogin_OIDCRealName(c *check.C) {
 		State: state,
 	})
 
-	authinfo := s.getCallbackAuthInfo(c)
+	authinfo := getCallbackAuthInfo(c, s.railsSpy)
 	c.Check(authinfo.FirstName, check.Equals, "Joe P.")
 	c.Check(authinfo.LastName, check.Equals, "Smith")
 }
@@ -355,7 +355,7 @@ func (s *LoginSuite) TestGoogleLogin_AlternateEmailAddresses(c *check.C) {
 		State: state,
 	})
 
-	authinfo := s.getCallbackAuthInfo(c)
+	authinfo := getCallbackAuthInfo(c, s.railsSpy)
 	c.Check(authinfo.Email, check.Equals, "joe.smith at primary.example.com")
 	c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith at home.example.com", "joe.smith at work.example.com"})
 }
@@ -384,7 +384,7 @@ func (s *LoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *check.C)
 		Code:  s.validCode,
 		State: state,
 	})
-	authinfo := s.getCallbackAuthInfo(c)
+	authinfo := getCallbackAuthInfo(c, s.railsSpy)
 	c.Check(authinfo.Email, check.Equals, "joe.smith at primary.example.com")
 	c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith at alternate.example.com", "jsmith+123 at preferdomainforusername.example.com"})
 	c.Check(authinfo.Username, check.Equals, "jsmith")
@@ -411,30 +411,12 @@ func (s *LoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) {
 		State: state,
 	})
 
-	authinfo := s.getCallbackAuthInfo(c)
+	authinfo := getCallbackAuthInfo(c, s.railsSpy)
 	c.Check(authinfo.Email, check.Equals, "joe.smith at work.example.com") // first verified email in People response
 	c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith at home.example.com"})
 	c.Check(authinfo.Username, check.Equals, "")
 }
 
-func (s *LoginSuite) getCallbackAuthInfo(c *check.C) (authinfo rpc.UserSessionAuthInfo) {
-	for _, dump := range s.railsSpy.RequestDumps {
-		c.Logf("spied request: %q", dump)
-		split := bytes.Split(dump, []byte("\r\n\r\n"))
-		c.Assert(split, check.HasLen, 2)
-		hdr, body := string(split[0]), string(split[1])
-		if strings.Contains(hdr, "POST /auth/controller/callback") {
-			vs, err := url.ParseQuery(body)
-			c.Check(json.Unmarshal([]byte(vs.Get("auth_info")), &authinfo), check.IsNil)
-			c.Check(err, check.IsNil)
-			sort.Strings(authinfo.AlternateEmails)
-			return
-		}
-	}
-	c.Error("callback not found")
-	return
-}
-
 func (s *LoginSuite) startLogin(c *check.C) (state string) {
 	// Initiate login, but instead of following the redirect to
 	// the provider, just grab state from the redirect URL.
@@ -463,3 +445,21 @@ func (s *LoginSuite) fakeToken(c *check.C, payload []byte) string {
 	c.Logf("fakeToken(%q) == %q", payload, t)
 	return t
 }
+
+func getCallbackAuthInfo(c *check.C, railsSpy *arvadostest.Proxy) (authinfo rpc.UserSessionAuthInfo) {
+	for _, dump := range railsSpy.RequestDumps {
+		c.Logf("spied request: %q", dump)
+		split := bytes.Split(dump, []byte("\r\n\r\n"))
+		c.Assert(split, check.HasLen, 2)
+		hdr, body := string(split[0]), string(split[1])
+		if strings.Contains(hdr, "POST /auth/controller/callback") {
+			vs, err := url.ParseQuery(body)
+			c.Check(json.Unmarshal([]byte(vs.Get("auth_info")), &authinfo), check.IsNil)
+			c.Check(err, check.IsNil)
+			sort.Strings(authinfo.AlternateEmails)
+			return
+		}
+	}
+	c.Error("callback not found")
+	return
+}
diff --git a/lib/controller/localdb/login_pam.go b/lib/controller/localdb/login_pam.go
new file mode 100644
index 000000000..51534ee54
--- /dev/null
+++ b/lib/controller/localdb/login_pam.go
@@ -0,0 +1,75 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+
+	"git.arvados.org/arvados.git/lib/controller/rpc"
+	"git.arvados.org/arvados.git/sdk/go/arvados"
+	"git.arvados.org/arvados.git/sdk/go/auth"
+	"git.arvados.org/arvados.git/sdk/go/ctxlog"
+	"github.com/msteinert/pam"
+	"github.com/sirupsen/logrus"
+)
+
+type pamLoginController struct {
+	Cluster    *arvados.Cluster
+	RailsProxy *railsProxy
+}
+
+func (ctrl *pamLoginController) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
+	return noopLogout(ctrl.Cluster, opts)
+}
+
+func (ctrl *pamLoginController) Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
+	errorMessage := ""
+	tx, err := pam.StartFunc(ctrl.Cluster.Login.PAMService, opts.Username, func(style pam.Style, message string) (string, error) {
+		ctxlog.FromContext(ctx).Debugf("pam conversation: style=%v message=%q", style, message)
+		switch style {
+		case pam.ErrorMsg:
+			ctxlog.FromContext(ctx).WithField("Message", message).Info("pam.ErrorMsg")
+			errorMessage = message
+			return "", nil
+		case pam.TextInfo:
+			ctxlog.FromContext(ctx).WithField("Message", message).Info("pam.TextInfo")
+			return "", nil
+		case pam.PromptEchoOn, pam.PromptEchoOff:
+			return opts.Password, nil
+		default:
+			return "", fmt.Errorf("unrecognized message style %d", style)
+		}
+	})
+	if err != nil {
+		return loginError(err)
+	}
+	err = tx.Authenticate(pam.DisallowNullAuthtok)
+	if err != nil {
+		return loginError(err)
+	}
+	if errorMessage != "" {
+		return loginError(errors.New(errorMessage))
+	}
+	user, err := tx.GetItem(pam.User)
+	if err != nil {
+		return loginError(err)
+	}
+	email := user
+	if domain := ctrl.Cluster.Login.PAMDefaultEmailDomain; domain != "" && !strings.Contains(email, "@") {
+		email = email + "@" + domain
+	}
+	ctxlog.FromContext(ctx).WithFields(logrus.Fields{"user": user, "email": email}).Debug("pam authentication succeeded")
+	ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{ctrl.Cluster.SystemRootToken}})
+	return ctrl.RailsProxy.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
+		ReturnTo: opts.Remote + "," + opts.ReturnTo,
+		AuthInfo: rpc.UserSessionAuthInfo{
+			Username: user,
+			Email:    email,
+		},
+	})
+}
diff --git a/lib/controller/localdb/login_pam_test.go b/lib/controller/localdb/login_pam_test.go
new file mode 100644
index 000000000..16e86b534
--- /dev/null
+++ b/lib/controller/localdb/login_pam_test.go
@@ -0,0 +1,81 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+	"context"
+	"io/ioutil"
+	"os"
+	"strings"
+
+	"git.arvados.org/arvados.git/lib/config"
+	"git.arvados.org/arvados.git/lib/controller/rpc"
+	"git.arvados.org/arvados.git/sdk/go/arvados"
+	"git.arvados.org/arvados.git/sdk/go/arvadostest"
+	"git.arvados.org/arvados.git/sdk/go/ctxlog"
+	check "gopkg.in/check.v1"
+)
+
+var _ = check.Suite(&PamSuite{})
+
+type PamSuite struct {
+	cluster  *arvados.Cluster
+	ctrl     *pamLoginController
+	railsSpy *arvadostest.Proxy
+}
+
+func (s *PamSuite) SetUpSuite(c *check.C) {
+	cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
+	c.Assert(err, check.IsNil)
+	s.cluster, err = cfg.GetCluster("")
+	c.Assert(err, check.IsNil)
+	s.cluster.Login.PAM = true
+	s.cluster.Login.PAMDefaultEmailDomain = "example.com"
+	s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
+	s.ctrl = &pamLoginController{
+		Cluster:    s.cluster,
+		RailsProxy: rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider),
+	}
+}
+
+func (s *PamSuite) TestLoginFailure(c *check.C) {
+	resp, err := s.ctrl.Login(context.Background(), arvados.LoginOptions{
+		Username: "bogususername",
+		Password: "boguspassword",
+		ReturnTo: "https://example.com/foo",
+	})
+	c.Check(err, check.IsNil)
+	c.Check(resp.RedirectLocation, check.Equals, "")
+	c.Check(resp.HTML.String(), check.Matches, `.*Authentication failure.*`)
+}
+
+// This test only runs if the ARVADOS_TEST_PAM_CREDENTIALS_FILE env
+// var is set. The credentials file should contain a valid username
+// and password, separated by \n.
+func (s *PamSuite) TestLoginSuccess(c *check.C) {
+	testCredsFile := os.Getenv("ARVADOS_TEST_PAM_CREDENTIALS_FILE")
+	if testCredsFile == "" {
+		c.Skip("no test credentials file given in ARVADOS_TEST_PAM_CREDENTIALS_FILE")
+		return
+	}
+	buf, err := ioutil.ReadFile(testCredsFile)
+	c.Assert(err, check.IsNil)
+	lines := strings.Split(string(buf), "\n")
+	c.Assert(len(lines), check.Equals, 2, check.Commentf("credentials file %s should contain \"username\\npassword\"", testCredsFile))
+	u, p := lines[0], lines[1]
+
+	resp, err := s.ctrl.Login(context.Background(), arvados.LoginOptions{
+		Username: u,
+		Password: p,
+		ReturnTo: "https://example.com/foo",
+	})
+	c.Check(err, check.IsNil)
+	c.Check(resp.RedirectLocation, check.Matches, `https://example.com/foo\?api_token=v2/zzzzz-gj3su-.*/.*`)
+	c.Check(resp.HTML.String(), check.Equals, "")
+
+	authinfo := getCallbackAuthInfo(c, s.railsSpy)
+	c.Check(authinfo.Email, check.Equals, u+"@"+s.cluster.Login.PAMDefaultEmailDomain)
+	c.Check(authinfo.AlternateEmails, check.DeepEquals, []string(nil))
+}
diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index 0c5d32e8b..ff0dcf75a 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -136,10 +136,12 @@ type DeleteOptions struct {
 }
 
 type LoginOptions struct {
-	ReturnTo string `json:"return_to"`        // On success, redirect to this target with api_token=xxx query param
-	Remote   string `json:"remote,omitempty"` // Salt token for remote Cluster ID
-	Code     string `json:"code,omitempty"`   // OAuth2 callback code
-	State    string `json:"state,omitempty"`  // OAuth2 callback state
+	ReturnTo string `json:"return_to"`          // On success, redirect to this target with api_token=xxx query param
+	Remote   string `json:"remote,omitempty"`   // Salt token for remote Cluster ID
+	Code     string `json:"code,omitempty"`     // OAuth2 callback code
+	State    string `json:"state,omitempty"`    // OAuth2 callback state
+	Username string `json:"username,omitempty"` // PAM username
+	Password string `json:"password,omitempty"` // PAM password
 }
 
 type LogoutOptions struct {
diff --git a/sdk/go/arvados/config.go b/sdk/go/arvados/config.go
index a70980cbd..71f6f85bf 100644
--- a/sdk/go/arvados/config.go
+++ b/sdk/go/arvados/config.go
@@ -136,6 +136,9 @@ type Cluster struct {
 		GoogleClientID                string
 		GoogleClientSecret            string
 		GoogleAlternateEmailAddresses bool
+		PAM                           bool
+		PAMService                    string
+		PAMDefaultEmailDomain         string
 		ProviderAppID                 string
 		ProviderAppSecret             string
 		LoginCluster                  string

-----------------------------------------------------------------------


hooks/post-receive
-- 




More information about the arvados-commits mailing list