Merge branch '17682-fix-stutter'
authorTom Clegg <tom@curii.com>
Wed, 26 May 2021 20:01:56 +0000 (16:01 -0400)
committerTom Clegg <tom@curii.com>
Wed, 26 May 2021 20:01:56 +0000 (16:01 -0400)
fixes #17682

Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom@curii.com>

21 files changed:
apps/workbench/Gemfile.lock
apps/workbench/app/models/arvados_resource_list.rb
apps/workbench/app/views/users/welcome.html.erb
doc/api/requests.html.textile.liquid
doc/api/tokens.html.textile.liquid
lib/config/config.default.yml
lib/config/export.go
lib/config/generated_config.go
lib/controller/auth_test.go
lib/controller/federation/list.go
lib/controller/integration_test.go
lib/controller/localdb/login.go
lib/controller/localdb/login_oidc.go
lib/controller/localdb/login_oidc_test.go
lib/crunchrun/copier.go
lib/crunchrun/copier_test.go
sdk/go/arvados/config.go
sdk/go/arvadostest/oidc_provider.go
services/api/Gemfile.lock
services/keep-web/cache.go
services/keep-web/handler_test.go

index e4ef96b194f84306b778870a36562c0b7d7b7703..0f29c543b4fbaa9fb2fbc1ef5d6a0f8adaec2eda 100644 (file)
@@ -189,7 +189,7 @@ GEM
     mimemagic (0.3.8)
       nokogiri (~> 1)
     mini_mime (1.0.2)
-    mini_portile2 (2.5.0)
+    mini_portile2 (2.5.1)
     minitest (5.10.3)
     mocha (1.8.0)
       metaclass (~> 0.0.1)
@@ -206,7 +206,7 @@ GEM
     net-ssh-gateway (2.0.0)
       net-ssh (>= 4.0.0)
     nio4r (2.5.7)
-    nokogiri (1.11.2)
+    nokogiri (1.11.5)
       mini_portile2 (~> 2.5.0)
       racc (~> 1.4)
     npm-rails (0.2.1)
index 99502bd56ed04951695e8bcb15704b64ea4b46e5..75a9429a43739f7f3c024f496d316b1d4e69cf86 100644 (file)
@@ -223,6 +223,7 @@ class ArvadosResourceList
     api_params[:filters] = @filters if @filters
     api_params[:distinct] = @distinct if @distinct
     api_params[:include_trash] = @include_trash if @include_trash
+    api_params[:cluster_id] = Rails.configuration.ClusterID
     if @fetch_multiple_pages
       # Default limit to (effectively) api server's MAX_LIMIT
       api_params[:limit] = 2**(0.size*8 - 1) - 1
index 0b98909e67d81f036558153e2fb662ff70af1f30..92fd6dad4615c1e618663c08e237e786c3f659fd 100644 (file)
@@ -47,18 +47,9 @@ SPDX-License-Identifier: AGPL-3.0 %>
       <%= raw(Rails.configuration.Workbench.WelcomePageHTML) %>
 
       <% case %>
-      <% when Rails.configuration.Login.Google.Enable %>
-      <% when Rails.configuration.Login.OpenIDConnect.Enable %>
-      <% when Rails.configuration.Login.SSO.Enable %>
-        <div class="pull-right">
-          <%= link_to arvados_api_client.arvados_login_url(return_to: request.url), class: "btn btn-primary" do %>
-          Log in to <%= Rails.configuration.Workbench.SiteName %>
-          <i class="fa fa-fw fa-arrow-circle-right"></i>
-          <% end %>
-        </div>
-      <% when Rails.configuration.Login.PAM.Enable %>
-      <% when Rails.configuration.Login.LDAP.Enable %>
-      <% when Rails.configuration.Login.Test.Enable %>
+      <% when Rails.configuration.Login.PAM.Enable,
+              Rails.configuration.Login.LDAP.Enable,
+              Rails.configuration.Login.Test.Enable %>
         <form id="login-form-tag" onsubmit="controller_password_authenticate(event)">
           <p>username <input type="text" class="form-control" name="login-username"
                             value="" id="login-username" style="width: 50%"
@@ -70,6 +61,13 @@ SPDX-License-Identifier: AGPL-3.0 %>
         <span style="color: red"><p id="login-authenticate-error"></p></span>
         <button type="submit" class="btn btn-primary">Log in</button>
         </form>
+      <% else %>
+        <div class="pull-right">
+          <%= link_to arvados_api_client.arvados_login_url(return_to: request.url), class: "btn btn-primary" do %>
+          Log in to <%= Rails.configuration.Workbench.SiteName %>
+          <i class="fa fa-fw fa-arrow-circle-right"></i>
+          <% end %>
+        </div>
       <% end %>
 
     </div>
index cee4728529dd6fd94e24bd400c710512e1a12e99..fc5957af5ff0c273681ed6fc95ec6ff603680d53 100644 (file)
@@ -42,7 +42,7 @@ $ curl -v -H "Authorization: Bearer xxxxapitokenxxxx" https://192.168.5.2:8000/a
 > ...
 </pre>
 
-On a cluster configured to use an OpenID Connect provider (including Google) as a login backend, an OpenID Connect access token can also be used in place of an Arvados API token. This is also supported on a cluster that delegates login to another cluster (LoginCluster) which in turn uses an OpenID Connect provider.
+On a cluster configured to use an OpenID Connect provider (other than Google) as a login backend, Arvados can be configured to accept an OpenID Connect access token in place of an Arvados API token. OIDC access tokens are also accepted by a cluster that delegates login to another cluster (LoginCluster) which in turn has this feature configured. See @Login.OpenIDConnect.AcceptAccessTokenScope@ in the "default config.yml file":{{site.baseurl}}/admin/config.html for details.
 
 <pre>
 $ curl -v -H "Authorization: Bearer xxxx-openid-connect-access-token-xxxx" https://192.168.5.2:8000/arvados/v1/collections
index c9321ae1df1d351bc119ecf2850e3e96e4f3f712..0935f9ba1d2a3bf7eb5c5bb7db4eb20b528ac3ed 100644 (file)
@@ -34,9 +34,10 @@ h3. Direct username/password authentication
 
 h3. Using an OpenID Connect access token
 
-On a cluster that uses OpenID Connect or Google as a login provider, or defers to a LoginCluster that does so, clients may present an access token instead of an Arvados API token.
+A cluster that uses OpenID Connect as a login provider can be configured to accept OIDC access tokens as well as Arvados API tokens (this is disabled by default; see @Login.OpenIDConnect.AcceptAccessToken@ in the "default config.yml file":{{site.baseurl}}/admin/config.html).
 # The client obtains an access token from the OpenID Connect provider via some method outside of Arvados.
 # The client presents the access token with an Arvados API request (e.g., request header @Authorization: Bearer xxxxaccesstokenxxxx@).
+# Depending on configuration, the API server decodes the access token (which must be a signed JWT) and confirms that it includes the required scope (see @Login.OpenIDConnect.AcceptAccessTokenScope@ in the "default config.yml file":{{site.baseurl}}/admin/config.html).
 # The API server uses the provider's UserInfo endpoint to validate the presented token.
 # If the token is valid, it is cached in the Arvados database and accepted in subsequent API calls for the next 10 minutes.
 
index 54deb34da3ad9e004fdce9987ed075cf20a6a89e..8ad2cb53fca8d20fce9a091f5a6b781e7e8c9835 100644 (file)
@@ -633,6 +633,23 @@ Clusters:
         AuthenticationRequestParameters:
           SAMPLE: ""
 
+        # Accept an OIDC access token as an API token if the OIDC
+        # provider's UserInfo endpoint accepts it.
+        #
+        # AcceptAccessTokenScope should also be used when enabling
+        # this feature.
+        AcceptAccessToken: false
+
+        # Before accepting an OIDC access token as an API token, first
+        # check that it is a JWT whose "scope" value includes this
+        # value. Example: "https://zzzzz.example.com/" (your Arvados
+        # API endpoint).
+        #
+        # If this value is empty and AcceptAccessToken is true, all
+        # access tokens will be accepted regardless of scope,
+        # including non-JWT tokens. This is not recommended.
+        AcceptAccessTokenScope: ""
+
       PAM:
         # (Experimental) Use PAM to authenticate users.
         Enable: false
index 5c0e9f270071b81792179c525cb47fa567955104..890d4ce4711eb4f06a00ada8e9e5adc63b2d7999 100644 (file)
@@ -157,6 +157,8 @@ var whitelist = map[string]bool{
        "Login.LDAP.UsernameAttribute":                        false,
        "Login.LoginCluster":                                  true,
        "Login.OpenIDConnect":                                 true,
+       "Login.OpenIDConnect.AcceptAccessToken":               false,
+       "Login.OpenIDConnect.AcceptAccessTokenScope":          false,
        "Login.OpenIDConnect.AuthenticationRequestParameters": false,
        "Login.OpenIDConnect.ClientID":                        false,
        "Login.OpenIDConnect.ClientSecret":                    false,
index 26c159c8cd100eb8f3c1c54f3d04e3d2793ce75d..9e59f8c9238606ed8b0926ad2841ce66d20e3565 100644 (file)
@@ -639,6 +639,23 @@ Clusters:
         AuthenticationRequestParameters:
           SAMPLE: ""
 
+        # Accept an OIDC access token as an API token if the OIDC
+        # provider's UserInfo endpoint accepts it.
+        #
+        # AcceptAccessTokenScope should also be used when enabling
+        # this feature.
+        AcceptAccessToken: false
+
+        # Before accepting an OIDC access token as an API token, first
+        # check that it is a JWT whose "scope" value includes this
+        # value. Example: "https://zzzzz.example.com/" (your Arvados
+        # API endpoint).
+        #
+        # If this value is empty and AcceptAccessToken is true, all
+        # access tokens will be accepted regardless of scope,
+        # including non-JWT tokens. This is not recommended.
+        AcceptAccessTokenScope: ""
+
       PAM:
         # (Experimental) Use PAM to authenticate users.
         Enable: false
index 01990620f6094dd10063df7e5e9410e082cade36..69458655ba0c8ce7caf9839d05eb5985f3fd0b7b 100644 (file)
@@ -94,6 +94,8 @@ func (s *AuthSuite) SetUpTest(c *check.C) {
        cluster.Login.OpenIDConnect.ClientSecret = s.fakeProvider.ValidClientSecret
        cluster.Login.OpenIDConnect.EmailClaim = "email"
        cluster.Login.OpenIDConnect.EmailVerifiedClaim = "email_verified"
+       cluster.Login.OpenIDConnect.AcceptAccessToken = true
+       cluster.Login.OpenIDConnect.AcceptAccessTokenScope = ""
 
        s.testHandler = &Handler{Cluster: cluster}
        s.testServer = newServerFromIntegrationTestEnv(c)
index 183557eb15a4780bbe3388e3185ae9064dc609e3..039caac574e479bdad181dfeed745dd3255640cf 100644 (file)
@@ -113,6 +113,11 @@ func (conn *Conn) splitListRequest(ctx context.Context, opts arvados.ListOptions
                _, err := fn(ctx, conn.cluster.ClusterID, conn.local, opts)
                return err
        }
+       if opts.ClusterID != "" {
+               // Client explicitly selected cluster
+               _, err := fn(ctx, conn.cluster.ClusterID, conn.chooseBackend(opts.ClusterID), opts)
+               return err
+       }
 
        cannotSplit := false
        var matchAllFilters map[string]bool
index 7b1dcbea6655bcf5f86b175c58ad2f9d5382b74d..44c99bf30f8c3a6ae9aa70b8306268b7c4c8fb6d 100644 (file)
@@ -113,6 +113,8 @@ func (s *IntegrationSuite) SetUpSuite(c *check.C) {
         ClientSecret: ` + s.oidcprovider.ValidClientSecret + `
         EmailClaim: email
         EmailVerifiedClaim: email_verified
+        AcceptAccessToken: true
+        AcceptAccessTokenScope: ""
 `
                } else {
                        yaml += `
index 01fa84ea4fe885ba59e7f56099cdfb41d21e2a8c..0d6f2ef027e8500c60fdf644e8f808fecd2f226a 100644 (file)
@@ -54,15 +54,17 @@ func chooseLoginController(cluster *arvados.Cluster, parent *Conn) loginControll
                }
        case wantOpenIDConnect:
                return &oidcLoginController{
-                       Cluster:            cluster,
-                       Parent:             parent,
-                       Issuer:             cluster.Login.OpenIDConnect.Issuer,
-                       ClientID:           cluster.Login.OpenIDConnect.ClientID,
-                       ClientSecret:       cluster.Login.OpenIDConnect.ClientSecret,
-                       AuthParams:         cluster.Login.OpenIDConnect.AuthenticationRequestParameters,
-                       EmailClaim:         cluster.Login.OpenIDConnect.EmailClaim,
-                       EmailVerifiedClaim: cluster.Login.OpenIDConnect.EmailVerifiedClaim,
-                       UsernameClaim:      cluster.Login.OpenIDConnect.UsernameClaim,
+                       Cluster:                cluster,
+                       Parent:                 parent,
+                       Issuer:                 cluster.Login.OpenIDConnect.Issuer,
+                       ClientID:               cluster.Login.OpenIDConnect.ClientID,
+                       ClientSecret:           cluster.Login.OpenIDConnect.ClientSecret,
+                       AuthParams:             cluster.Login.OpenIDConnect.AuthenticationRequestParameters,
+                       EmailClaim:             cluster.Login.OpenIDConnect.EmailClaim,
+                       EmailVerifiedClaim:     cluster.Login.OpenIDConnect.EmailVerifiedClaim,
+                       UsernameClaim:          cluster.Login.OpenIDConnect.UsernameClaim,
+                       AcceptAccessToken:      cluster.Login.OpenIDConnect.AcceptAccessToken,
+                       AcceptAccessTokenScope: cluster.Login.OpenIDConnect.AcceptAccessTokenScope,
                }
        case wantSSO:
                return &ssoLoginController{Parent: parent}
index a435b014d967deafae3a72060809a3e843ecc975..61dc5c816b35661f39c4a800ab17f1bf55325f06 100644 (file)
@@ -35,6 +35,7 @@ import (
        "golang.org/x/oauth2"
        "google.golang.org/api/option"
        "google.golang.org/api/people/v1"
+       "gopkg.in/square/go-jose.v2/jwt"
 )
 
 var (
@@ -45,16 +46,18 @@ var (
 )
 
 type oidcLoginController struct {
-       Cluster            *arvados.Cluster
-       Parent             *Conn
-       Issuer             string // OIDC issuer URL, e.g., "https://accounts.google.com"
-       ClientID           string
-       ClientSecret       string
-       UseGooglePeopleAPI bool              // Use Google People API to look up alternate email addresses
-       EmailClaim         string            // OpenID claim to use as email address; typically "email"
-       EmailVerifiedClaim string            // If non-empty, ensure claim value is true before accepting EmailClaim; typically "email_verified"
-       UsernameClaim      string            // If non-empty, use as preferred username
-       AuthParams         map[string]string // Additional parameters to pass with authentication request
+       Cluster                *arvados.Cluster
+       Parent                 *Conn
+       Issuer                 string // OIDC issuer URL, e.g., "https://accounts.google.com"
+       ClientID               string
+       ClientSecret           string
+       UseGooglePeopleAPI     bool              // Use Google People API to look up alternate email addresses
+       EmailClaim             string            // OpenID claim to use as email address; typically "email"
+       EmailVerifiedClaim     string            // If non-empty, ensure claim value is true before accepting EmailClaim; typically "email_verified"
+       UsernameClaim          string            // If non-empty, use as preferred username
+       AcceptAccessToken      bool              // Accept access tokens as API tokens
+       AcceptAccessTokenScope string            // If non-empty, don't accept access tokens as API tokens unless they contain this scope
+       AuthParams             map[string]string // Additional parameters to pass with authentication request
 
        // override Google People API base URL for testing purposes
        // (normally empty, set by google pkg to
@@ -134,6 +137,7 @@ func (ctrl *oidcLoginController) Login(ctx context.Context, opts arvados.LoginOp
        if !ok {
                return loginError(errors.New("error in OAuth2 exchange: no ID token in OAuth2 token"))
        }
+       ctxlog.FromContext(ctx).WithField("rawIDToken", rawIDToken).Debug("oauth2Token provided ID token")
        idToken, err := ctrl.verifier.Verify(ctx, rawIDToken)
        if err != nil {
                return loginError(fmt.Errorf("error verifying ID token: %s", err))
@@ -448,6 +452,10 @@ func (ta *oidcTokenAuthorizer) registerToken(ctx context.Context, tok string) er
        if err != nil {
                return fmt.Errorf("error setting up OpenID Connect provider: %s", err)
        }
+       if ok, err := ta.checkAccessTokenScope(ctx, tok); err != nil || !ok {
+               ta.cache.Add(tok, time.Now().Add(tokenCacheNegativeTTL))
+               return err
+       }
        oauth2Token := &oauth2.Token{
                AccessToken: tok,
        }
@@ -494,3 +502,38 @@ func (ta *oidcTokenAuthorizer) registerToken(ctx context.Context, tok string) er
        ta.cache.Add(tok, aca)
        return nil
 }
+
+// Check that the provided access token is a JWT with the required
+// scope. If it is a valid JWT but missing the required scope, we
+// return a 403 error, otherwise true (acceptable as an API token) or
+// false (pass through unmodified).
+//
+// Return false if configured not to accept access tokens at all.
+//
+// Note we don't check signature or expiry here. We are relying on the
+// caller to verify those separately (e.g., by calling the UserInfo
+// endpoint).
+func (ta *oidcTokenAuthorizer) checkAccessTokenScope(ctx context.Context, tok string) (bool, error) {
+       if !ta.ctrl.AcceptAccessToken {
+               return false, nil
+       } else if ta.ctrl.AcceptAccessTokenScope == "" {
+               return true, nil
+       }
+       var claims struct {
+               Scope string `json:"scope"`
+       }
+       if t, err := jwt.ParseSigned(tok); err != nil {
+               ctxlog.FromContext(ctx).WithError(err).Debug("error parsing jwt")
+               return false, nil
+       } else if err = t.UnsafeClaimsWithoutVerification(&claims); err != nil {
+               ctxlog.FromContext(ctx).WithError(err).Debug("error extracting jwt claims")
+               return false, nil
+       }
+       for _, s := range strings.Split(claims.Scope, " ") {
+               if s == ta.ctrl.AcceptAccessTokenScope {
+                       return true, nil
+               }
+       }
+       ctxlog.FromContext(ctx).WithFields(logrus.Fields{"have": claims.Scope, "need": ta.ctrl.AcceptAccessTokenScope}).Infof("unacceptable access token scope")
+       return false, httpserver.ErrorWithStatus(errors.New("unacceptable access token scope"), http.StatusUnauthorized)
+}
index e3c72adddcdbbf76650fada2a8eb8401add88431..c9d6133c480319b9129397ea076068d67bb4a3f5 100644 (file)
@@ -208,22 +208,25 @@ func (s *OIDCLoginSuite) TestOIDCAuthorizer(c *check.C) {
        json.Unmarshal([]byte(fmt.Sprintf("%q", s.fakeProvider.Issuer.URL)), &s.cluster.Login.OpenIDConnect.Issuer)
        s.cluster.Login.OpenIDConnect.ClientID = "oidc#client#id"
        s.cluster.Login.OpenIDConnect.ClientSecret = "oidc#client#secret"
+       s.cluster.Login.OpenIDConnect.AcceptAccessToken = true
+       s.cluster.Login.OpenIDConnect.AcceptAccessTokenScope = ""
        s.fakeProvider.ValidClientID = "oidc#client#id"
        s.fakeProvider.ValidClientSecret = "oidc#client#secret"
        db := arvadostest.DB(c, s.cluster)
 
        tokenCacheTTL = time.Millisecond
        tokenCacheRaceWindow = time.Millisecond
+       tokenCacheNegativeTTL = time.Millisecond
 
        oidcAuthorizer := OIDCAccessTokenAuthorizer(s.cluster, func(context.Context) (*sqlx.DB, error) { return db, nil })
        accessToken := s.fakeProvider.ValidAccessToken()
 
        mac := hmac.New(sha256.New, []byte(s.cluster.SystemRootToken))
        io.WriteString(mac, accessToken)
-       hmac := fmt.Sprintf("%x", mac.Sum(nil))
+       apiToken := fmt.Sprintf("%x", mac.Sum(nil))
 
        cleanup := func() {
-               _, err := db.Exec(`delete from api_client_authorizations where api_token=$1`, hmac)
+               _, err := db.Exec(`delete from api_client_authorizations where api_token=$1`, apiToken)
                c.Check(err, check.IsNil)
        }
        cleanup()
@@ -237,7 +240,7 @@ func (s *OIDCLoginSuite) TestOIDCAuthorizer(c *check.C) {
                c.Assert(creds.Tokens, check.HasLen, 1)
                c.Check(creds.Tokens[0], check.Equals, accessToken)
 
-               err := db.QueryRowContext(ctx, `select expires_at at time zone 'UTC' from api_client_authorizations where api_token=$1`, hmac).Scan(&exp1)
+               err := db.QueryRowContext(ctx, `select expires_at at time zone 'UTC' from api_client_authorizations where api_token=$1`, apiToken).Scan(&exp1)
                c.Check(err, check.IsNil)
                c.Check(exp1.Sub(time.Now()) > -time.Second, check.Equals, true)
                c.Check(exp1.Sub(time.Now()) < time.Second, check.Equals, true)
@@ -245,17 +248,58 @@ func (s *OIDCLoginSuite) TestOIDCAuthorizer(c *check.C) {
        })(ctx, nil)
 
        // If the token is used again after the in-memory cache
-       // expires, oidcAuthorizer must re-checks the token and update
+       // expires, oidcAuthorizer must re-check the token and update
        // the expires_at value in the database.
        time.Sleep(3 * time.Millisecond)
        oidcAuthorizer.WrapCalls(func(ctx context.Context, opts interface{}) (interface{}, error) {
                var exp time.Time
-               err := db.QueryRowContext(ctx, `select expires_at at time zone 'UTC' from api_client_authorizations where api_token=$1`, hmac).Scan(&exp)
+               err := db.QueryRowContext(ctx, `select expires_at at time zone 'UTC' from api_client_authorizations where api_token=$1`, apiToken).Scan(&exp)
                c.Check(err, check.IsNil)
                c.Check(exp.Sub(exp1) > 0, check.Equals, true)
                c.Check(exp.Sub(exp1) < time.Second, check.Equals, true)
                return nil, nil
        })(ctx, nil)
+
+       s.fakeProvider.AccessTokenPayload = map[string]interface{}{"scope": "openid profile foobar"}
+       accessToken = s.fakeProvider.ValidAccessToken()
+       ctx = auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{accessToken}})
+
+       mac = hmac.New(sha256.New, []byte(s.cluster.SystemRootToken))
+       io.WriteString(mac, accessToken)
+       apiToken = fmt.Sprintf("%x", mac.Sum(nil))
+
+       for _, trial := range []struct {
+               configEnable bool
+               configScope  string
+               acceptable   bool
+               shouldRun    bool
+       }{
+               {true, "foobar", true, true},
+               {true, "foo", false, false},
+               {true, "", true, true},
+               {false, "", false, true},
+               {false, "foobar", false, true},
+       } {
+               c.Logf("trial = %+v", trial)
+               cleanup()
+               s.cluster.Login.OpenIDConnect.AcceptAccessToken = trial.configEnable
+               s.cluster.Login.OpenIDConnect.AcceptAccessTokenScope = trial.configScope
+               oidcAuthorizer = OIDCAccessTokenAuthorizer(s.cluster, func(context.Context) (*sqlx.DB, error) { return db, nil })
+               checked := false
+               oidcAuthorizer.WrapCalls(func(ctx context.Context, opts interface{}) (interface{}, error) {
+                       var n int
+                       err := db.QueryRowContext(ctx, `select count(*) from api_client_authorizations where api_token=$1`, apiToken).Scan(&n)
+                       c.Check(err, check.IsNil)
+                       if trial.acceptable {
+                               c.Check(n, check.Equals, 1)
+                       } else {
+                               c.Check(n, check.Equals, 0)
+                       }
+                       checked = true
+                       return nil, nil
+               })(ctx, nil)
+               c.Check(checked, check.Equals, trial.shouldRun)
+       }
 }
 
 func (s *OIDCLoginSuite) TestGenericOIDCLogin(c *check.C) {
index 1b0f168b88856e8251108f11e928321b5d642c0b..132101028ea4d6d5b6b8a76df5238d7ceb0effb7 100644 (file)
@@ -331,8 +331,8 @@ func (cp *copier) walkHostFS(dest, src string, maxSymlinks int, includeMounts bo
                })
                return nil
        }
-
-       return fmt.Errorf("Unsupported file type (mode %o) in output dir: %q", fi.Mode(), src)
+       cp.logger.Printf("Skipping unsupported file type (mode %o) in output dir: %q", fi.Mode(), src)
+       return nil
 }
 
 // Return the host path that was mounted at the given path in the
index 777b715d76dd8bb57e9d5b34309ee70b356df888..07fd795efe45a75c6390520a88de39e479ca1f72 100644 (file)
@@ -5,27 +5,31 @@
 package crunchrun
 
 import (
+       "bytes"
        "io"
        "io/ioutil"
        "os"
+       "syscall"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
        "git.arvados.org/arvados.git/sdk/go/arvadosclient"
        "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "github.com/sirupsen/logrus"
        check "gopkg.in/check.v1"
 )
 
 var _ = check.Suite(&copierSuite{})
 
 type copierSuite struct {
-       cp copier
+       cp  copier
+       log bytes.Buffer
 }
 
 func (s *copierSuite) SetUpTest(c *check.C) {
-       tmpdir, err := ioutil.TempDir("", "crunch-run.test.")
-       c.Assert(err, check.IsNil)
+       tmpdir := c.MkDir()
        api, err := arvadosclient.MakeArvadosClient()
        c.Assert(err, check.IsNil)
+       s.log = bytes.Buffer{}
        s.cp = copier{
                client:        arvados.NewClientFromEnv(),
                arvClient:     api,
@@ -37,13 +41,10 @@ func (s *copierSuite) SetUpTest(c *check.C) {
                secretMounts: map[string]arvados.Mount{
                        "/secret_text": {Kind: "text", Content: "xyzzy"},
                },
+               logger: &logrus.Logger{Out: &s.log, Formatter: &logrus.TextFormatter{}, Level: logrus.InfoLevel},
        }
 }
 
-func (s *copierSuite) TearDownTest(c *check.C) {
-       os.RemoveAll(s.cp.hostOutputDir)
-}
-
 func (s *copierSuite) TestEmptyOutput(c *check.C) {
        err := s.cp.walkMount("", s.cp.ctrOutputDir, 10, true)
        c.Check(err, check.IsNil)
@@ -59,6 +60,8 @@ func (s *copierSuite) TestRegularFilesAndDirs(c *check.C) {
        _, err = io.WriteString(f, "foo")
        c.Assert(err, check.IsNil)
        c.Assert(f.Close(), check.IsNil)
+       err = syscall.Mkfifo(s.cp.hostOutputDir+"/dir1/fifo", 0644)
+       c.Assert(err, check.IsNil)
 
        err = s.cp.walkMount("", s.cp.ctrOutputDir, 10, true)
        c.Check(err, check.IsNil)
@@ -67,6 +70,7 @@ func (s *copierSuite) TestRegularFilesAndDirs(c *check.C) {
                {src: os.DevNull, dst: "/dir1/dir2/dir3/.keep"},
                {src: s.cp.hostOutputDir + "/dir1/foo", dst: "/dir1/foo", size: 3},
        })
+       c.Check(s.log.String(), check.Matches, `.* msg="Skipping unsupported file type \(mode 200000644\) in output dir: \\"/ctr/outdir/dir1/fifo\\""\n`)
 }
 
 func (s *copierSuite) TestSymlinkCycle(c *check.C) {
index 2c6db42d133652d535594b6e13d46c035cf5e5ea..65e2ff5381e84e9ab4259e0b54b3d033e20d9508 100644 (file)
@@ -167,6 +167,8 @@ type Cluster struct {
                        EmailClaim                      string
                        EmailVerifiedClaim              string
                        UsernameClaim                   string
+                       AcceptAccessToken               bool
+                       AcceptAccessTokenScope          string
                        AuthenticationRequestParameters map[string]string
                }
                PAM struct {
index 96205f919fa79b813721af4304bdbc27084e4b7f..de21302e5a048dfbca340abf24cb6c5359de7305 100644 (file)
@@ -17,6 +17,7 @@ import (
 
        "gopkg.in/check.v1"
        "gopkg.in/square/go-jose.v2"
+       "gopkg.in/square/go-jose.v2/jwt"
 )
 
 type OIDCProvider struct {
@@ -25,9 +26,10 @@ type OIDCProvider struct {
        ValidClientID     string
        ValidClientSecret string
        // desired response from token endpoint
-       AuthEmail         string
-       AuthEmailVerified bool
-       AuthName          string
+       AuthEmail          string
+       AuthEmailVerified  bool
+       AuthName           string
+       AccessTokenPayload map[string]interface{}
 
        PeopleAPIResponse map[string]interface{}
 
@@ -44,11 +46,13 @@ func NewOIDCProvider(c *check.C) *OIDCProvider {
        c.Assert(err, check.IsNil)
        p.Issuer = httptest.NewServer(http.HandlerFunc(p.serveOIDC))
        p.PeopleAPI = httptest.NewServer(http.HandlerFunc(p.servePeopleAPI))
+       p.AccessTokenPayload = map[string]interface{}{"sub": "example"}
        return p
 }
 
 func (p *OIDCProvider) ValidAccessToken() string {
-       return p.fakeToken([]byte("fake access token"))
+       buf, _ := json.Marshal(p.AccessTokenPayload)
+       return p.fakeToken(buf)
 }
 
 func (p *OIDCProvider) serveOIDC(w http.ResponseWriter, req *http.Request) {
@@ -118,7 +122,8 @@ func (p *OIDCProvider) serveOIDC(w http.ResponseWriter, req *http.Request) {
        case "/auth":
                w.WriteHeader(http.StatusInternalServerError)
        case "/userinfo":
-               if authhdr := req.Header.Get("Authorization"); strings.TrimPrefix(authhdr, "Bearer ") != p.ValidAccessToken() {
+               authhdr := req.Header.Get("Authorization")
+               if _, err := jwt.ParseSigned(strings.TrimPrefix(authhdr, "Bearer ")); err != nil {
                        p.c.Logf("OIDCProvider: bad auth %q", authhdr)
                        w.WriteHeader(http.StatusUnauthorized)
                        return
index 5dbdb07f2ce11c3abdb54d1fb7bd02368874e0be..58504d057a9a085f9afbc7a24dff161d8f96b340 100644 (file)
@@ -154,7 +154,7 @@ GEM
     mimemagic (0.3.8)
       nokogiri (~> 1)
     mini_mime (1.0.2)
-    mini_portile2 (2.5.0)
+    mini_portile2 (2.5.1)
     minitest (5.10.3)
     mocha (1.8.0)
       metaclass (~> 0.0.1)
@@ -170,7 +170,7 @@ GEM
     net-ssh-gateway (2.0.0)
       net-ssh (>= 4.0.0)
     nio4r (2.5.7)
-    nokogiri (1.11.2)
+    nokogiri (1.11.5)
       mini_portile2 (~> 2.5.0)
       racc (~> 1.4)
     oauth2 (1.4.1)
index 07db7a016f7bbd25442b4b7500e53633bd4b0059..9bdecdca1c40cfd2662197e39f4c129fc146932e 100644 (file)
@@ -195,7 +195,7 @@ func (c *cache) Update(client *arvados.Client, coll arvados.Collection, fs arvad
                },
        })
        if err == nil {
-               c.collections.Add(client.AuthToken+"\000"+coll.PortableDataHash, &cachedCollection{
+               c.collections.Add(client.AuthToken+"\000"+updated.PortableDataHash, &cachedCollection{
                        expire:     time.Now().Add(time.Duration(c.config.TTL)),
                        collection: &updated,
                })
index 3ff7cb1926b69d36ccd8f683ec544ec977fd7aef..446d591bfd715224651c1d9667e0c451e81f664e 100644 (file)
@@ -1118,6 +1118,62 @@ func (s *IntegrationSuite) TestKeepClientBlockCache(c *check.C) {
        c.Check(keepclient.DefaultBlockCache.MaxBlocks, check.Equals, 42)
 }
 
+// Writing to a collection shouldn't affect its entry in the
+// PDH-to-manifest cache.
+func (s *IntegrationSuite) TestCacheWriteCollectionSamePDH(c *check.C) {
+       arv, err := arvadosclient.MakeArvadosClient()
+       c.Assert(err, check.Equals, nil)
+       arv.ApiToken = arvadostest.ActiveToken
+
+       u := mustParseURL("http://x.example/testfile")
+       req := &http.Request{
+               Method:     "GET",
+               Host:       u.Host,
+               URL:        u,
+               RequestURI: u.RequestURI(),
+               Header:     http.Header{"Authorization": {"Bearer " + arv.ApiToken}},
+       }
+
+       checkWithID := func(id string, status int) {
+               req.URL.Host = strings.Replace(id, "+", "-", -1) + ".example"
+               req.Host = req.URL.Host
+               resp := httptest.NewRecorder()
+               s.testServer.Handler.ServeHTTP(resp, req)
+               c.Check(resp.Code, check.Equals, status)
+       }
+
+       var colls [2]arvados.Collection
+       for i := range colls {
+               err := arv.Create("collections",
+                       map[string]interface{}{
+                               "ensure_unique_name": true,
+                               "collection": map[string]interface{}{
+                                       "name": "test collection",
+                               },
+                       }, &colls[i])
+               c.Assert(err, check.Equals, nil)
+       }
+
+       // Populate cache with empty collection
+       checkWithID(colls[0].PortableDataHash, http.StatusNotFound)
+
+       // write a file to colls[0]
+       reqPut := *req
+       reqPut.Method = "PUT"
+       reqPut.URL.Host = colls[0].UUID + ".example"
+       reqPut.Host = req.URL.Host
+       reqPut.Body = ioutil.NopCloser(bytes.NewBufferString("testdata"))
+       resp := httptest.NewRecorder()
+       s.testServer.Handler.ServeHTTP(resp, &reqPut)
+       c.Check(resp.Code, check.Equals, http.StatusCreated)
+
+       // new file should not appear in colls[1]
+       checkWithID(colls[1].PortableDataHash, http.StatusNotFound)
+       checkWithID(colls[1].UUID, http.StatusNotFound)
+
+       checkWithID(colls[0].UUID, http.StatusOK)
+}
+
 func copyHeader(h http.Header) http.Header {
        hc := http.Header{}
        for k, v := range h {