Merge branch '17464-download-activity' refs #17464
authorPeter Amstutz <peter.amstutz@curii.com>
Wed, 30 Jun 2021 19:49:01 +0000 (15:49 -0400)
committerPeter Amstutz <peter.amstutz@curii.com>
Wed, 30 Jun 2021 19:49:01 +0000 (15:49 -0400)
Arvados-DCO-1.1-Signed-off-by: Peter Amstutz <peter.amstutz@curii.com>

15 files changed:
doc/admin/restricting-upload-download.html.textile.liquid [new file with mode: 0644]
lib/config/config.default.yml
lib/config/export.go
lib/config/generated_config.go
sdk/go/arvados/config.go
sdk/go/arvados/fs_collection.go
sdk/go/arvadostest/fixtures.go
services/keep-web/cache.go
services/keep-web/handler.go
services/keep-web/handler_test.go
services/keep-web/s3.go
services/keep-web/server_test.go
services/keepproxy/keepproxy.go
services/keepproxy/keepproxy_test.go
tools/user-activity/arvados_user_activity/main.py

diff --git a/doc/admin/restricting-upload-download.html.textile.liquid b/doc/admin/restricting-upload-download.html.textile.liquid
new file mode 100644 (file)
index 0000000..ea10752
--- /dev/null
@@ -0,0 +1,148 @@
+---
+layout: default
+navsection: admin
+title: Restricting upload or download
+...
+
+{% comment %}
+Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: CC-BY-SA-3.0
+{% endcomment %}
+
+For some use cases, you may want to limit the ability of users to upload or download data from outside the cluster.  (By "outside" we mean from networks other than the cluster's own private network).  For example, this makes it possible to share restricted data sets with users so that they may run their own data analysis on the cluster, while preventing them from easily downloading the data set to their local workstation.
+
+This feature exists in addition to the existing Arvados permission system.  Users can only download from collections they have @read@ access to, and can only upload to projects and collections they have @write@ access to.
+
+There are two services involved in accessing data from outside the cluster.
+
+h2. Keepproxy Permissions
+
+Permitting @keeproxy@ makes it possible to use @arv-put@ and @arv-get@, and upload from Workbench 1.  It works in terms of individual 64 MiB keep blocks.  It prints a log each time a user uploads or downloads an individual block.
+
+The default policy allows anyone to upload or download.
+
+<pre>
+    Collections:
+      KeepproxyPermission:
+        User:
+          Download: true
+          Upload: true
+        Admin:
+          Download: true
+          Upload: true
+</pre>
+
+If you create a sharing link as an admin user, and then give someone the token from the sharing link to download a file using @arv-get@, because the downloader is anonymous, the download permission will be restricted based on the "User" role and not the "Admin" role.
+
+h2. WebDAV and S3 API Permissions
+
+Permitting @WebDAV@ makes it possible to use WebDAV, S3 API, download from Workbench 1, and upload/download with Workbench 2.  It works in terms of individual files.  It prints a log each time a user uploads or downloads a file.  When @WebDAVLogEvents@ (default true) is enabled, it also adds an entry into the API server @logs@ table.
+
+When a user attempts to upload or download from a service without permission, they will receive a @403 Forbidden@ response.  This only applies to file content.
+
+Denying download permission does not deny access to access to XML file listings with PROPFIND, or auto-generated HTML documents containing file listings.
+
+Denying upload permission does not deny other operations that modify collections without directly accessing file content, such as MOVE and COPY.
+
+The default policy allows anyone to upload or download.
+
+<pre>
+    Collections:
+      WebDAVPermisison:
+        User:
+          Download: true
+          Upload: true
+        Admin:
+          Download: true
+          Upload: true
+      WebDAVLogEvents: true
+</pre>
+
+If you create a sharing link as an admin user, and then give someone the token from the sharing link to download a file over HTTP (WebDAV or S3 API), because the downloader is anonymous, the download permission will be restricted based on the "User" role and not the "Admin" role.
+
+h2. Shell node and container permissions
+
+Be aware that even when upload and download from outside the network is not allowed, a user who has access to a shell node or runs a container still has internal access to Keep.  (This is necessary to be able to run workflows).  From the shell node or container, a user could send data outside the network by some other method, although this requires more intent than accidentally clicking on a link and downloading a file.  It is possible to set up a firewall to prevent shell and compute nodes from making connections to hosts outside the private network.  Exactly how to configure firewalls is out of scope for this page, as it depends on the specific network infrastructure of your cluster.
+
+h2. Choosing a policy
+
+This distinction between WebDAV and Keepproxy is important for auditing.  WebDAV records 'upload' and 'download' events on the API server that are included in the "User Activity Report":user-activity.html ,  whereas @keepproxy@ only logs upload and download of individual blocks, which require a reverse lookup to determine the collection(s) and file(s) a block is associated with.
+
+You set separate permissions for @WebDAV@ and @Keepproxy@, with separate policies for regular users and admin users.
+
+These policies apply to only access from outside the cluster, using Workbench or Arvados CLI tools.
+
+The @WebDAVLogEvents@ option should be enabled if you intend to the run the "User Activity Report":user-activity.html .  If you don't need audits, or you are running a site that is mostly serving public data to anonymous downloaders, you can disable in to avoid the extra API server request.
+
+h3. Audited downloads
+
+For ease of access auditing, this policy prevents downloads using @arv-get@.  Downloads through WebDAV and S3 API are permitted, but logged.  Uploads are allowed.
+
+<pre>
+    Collections:
+      WebDAVPermisison:
+        User:
+          Download: true
+          Upload: true
+        Admin:
+          Download: true
+          Upload: true
+
+      KeepproxyPermission:
+        User:
+          Download: false
+          Upload: true
+        Admin:
+          Download: false
+          Upload: true
+      WebDAVLogEvents: true
+</pre>
+
+h3. Disallow downloads by regular users
+
+This policy prevents regular users (non-admin) from downloading data.  Uploading is allowed.  This supports the case where restricted data sets are shared with users so that they may run their own data analysis on the cluster, while preventing them from downloading the data set to their local workstation.  Be aware that users won't be able to download the results of their analysis, either, requiring an admin in the loop or some other process to release results.
+
+<pre>
+    Collections:
+      WebDAVPermisison:
+        User:
+          Download: false
+          Upload: true
+        Admin:
+          Download: true
+          Upload: true
+
+      KeepproxyPermission:
+        User:
+          Download: false
+          Upload: true
+        Admin:
+          Download: true
+          Upload: true
+      WebDAVLogEvents: true
+</pre>
+
+h3. Disallow uploads by regular users
+
+This policy is suitable for an installation where data is being shared with a group of users who are allowed to download the data, but not permitted to store their own data on the cluster.
+
+<pre>
+    Collections:
+      WebDAVPermisison:
+        User:
+          Download: true
+          Upload: false
+        Admin:
+          Download: true
+          Upload: true
+
+      KeepproxyPermission:
+        User:
+          Download: true
+          Upload: false
+        Admin:
+          Download: true
+          Upload: true
+      WebDAVLogEvents: true
+</pre>
index 645da56718d6ae10bda4fb7dd366541d034a20f8..e28d5cbb7f0cd09b3ad559f6cab0c9a9967c10d5 100644 (file)
@@ -551,6 +551,34 @@ Clusters:
         # Persistent sessions.
         MaxSessions: 100
 
+      # Selectively set permissions for regular users and admins to
+      # download or upload data files using the upload/download
+      # features for Workbench, WebDAV and S3 API support.
+      WebDAVPermission:
+        User:
+          Download: true
+          Upload: true
+        Admin:
+          Download: true
+          Upload: true
+
+      # Selectively set permissions for regular users and admins to be
+      # able to download or upload blocks using arv-put and
+      # arv-get from outside the cluster.
+      KeepproxyPermission:
+        User:
+          Download: true
+          Upload: true
+        Admin:
+          Download: true
+          Upload: true
+
+      # Post upload / download events to the API server logs table, so
+      # that they can be included in the arv-user-activity report.
+      # You can disable this if you find that it is creating excess
+      # load on the API server and you don't need it.
+      WebDAVLogEvents: true
+
     Login:
       # One of the following mechanisms (Google, PAM, LDAP, or
       # LoginCluster) should be enabled; see
index 32a528b3c73835cef815f056d781941386a92695..8753b52f27e17dee80958fd32ab25e46cda2f9fb 100644 (file)
@@ -106,6 +106,9 @@ var whitelist = map[string]bool{
        "Collections.TrashSweepInterval":                      false,
        "Collections.TrustAllContent":                         false,
        "Collections.WebDAVCache":                             false,
+       "Collections.KeepproxyPermission":                     false,
+       "Collections.WebDAVPermission":                        false,
+       "Collections.WebDAVLogEvents":                         false,
        "Containers":                                          true,
        "Containers.CloudVMs":                                 false,
        "Containers.CrunchRunArgumentsList":                   false,
index 1bdc269c083b00b4e3e6d273c87d5a6c97726412..b15bf7eebc29facda6f1a0e2670c5482f83055cf 100644 (file)
@@ -557,6 +557,34 @@ Clusters:
         # Persistent sessions.
         MaxSessions: 100
 
+      # Selectively set permissions for regular users and admins to
+      # download or upload data files using the upload/download
+      # features for Workbench, WebDAV and S3 API support.
+      WebDAVPermission:
+        User:
+          Download: true
+          Upload: true
+        Admin:
+          Download: true
+          Upload: true
+
+      # Selectively set permissions for regular users and admins to be
+      # able to download or upload blocks using arv-put and
+      # arv-get from outside the cluster.
+      KeepproxyPermission:
+        User:
+          Download: true
+          Upload: true
+        Admin:
+          Download: true
+          Upload: true
+
+      # Post upload / download events to the API server logs table, so
+      # that they can be included in the arv-user-activity report.
+      # You can disable this if you find that it is creating excess
+      # load on the API server and you don't need it.
+      WebDAVLogEvents: true
+
     Login:
       # One of the following mechanisms (Google, PAM, LDAP, or
       # LoginCluster) should be enabled; see
index 7fdea2c74059224c0718fe60fcd099df7acb3843..6e59828a3cbf5656fef1e6c7fc790ca9d3b6268f 100644 (file)
@@ -68,6 +68,16 @@ type WebDAVCacheConfig struct {
        MaxSessions          int
 }
 
+type UploadDownloadPermission struct {
+       Upload   bool
+       Download bool
+}
+
+type UploadDownloadRolePermissions struct {
+       User  UploadDownloadPermission
+       Admin UploadDownloadPermission
+}
+
 type Cluster struct {
        ClusterID       string `json:"-"`
        ManagementToken string
@@ -130,6 +140,10 @@ type Cluster struct {
                BalanceTimeout           Duration
 
                WebDAVCache WebDAVCacheConfig
+
+               KeepproxyPermission UploadDownloadRolePermissions
+               WebDAVPermission    UploadDownloadRolePermissions
+               WebDAVLogEvents     bool
        }
        Git struct {
                GitCommand   string
index 22e2b31d57e08d6c5dc813017c62b950f61aac01..b743ab368e33f69a5c1710d63dc410af8a380ffc 100644 (file)
@@ -674,6 +674,7 @@ func (dn *dirnode) Child(name string, replace func(inode) (inode, error)) (inode
                        if err != nil {
                                return nil, err
                        }
+                       coll.UUID = dn.fs.uuid
                        data, err := json.Marshal(&coll)
                        if err == nil {
                                data = append(data, '\n')
index a4d7e88b2354ab6c4258e5bbd0269e1247e25497..4b7ad6dd59fa426e8b1e71c546ee43a851d99c54 100644 (file)
@@ -10,6 +10,7 @@ const (
        ActiveToken             = "3kg6k6lzmp9kj5cpkcoxie963cmvjahbt2fod9zru30k1jqdmi"
        ActiveTokenUUID         = "zzzzz-gj3su-077z32aux8dg2s1"
        ActiveTokenV2           = "v2/zzzzz-gj3su-077z32aux8dg2s1/3kg6k6lzmp9kj5cpkcoxie963cmvjahbt2fod9zru30k1jqdmi"
+       AdminUserUUID           = "zzzzz-tpzed-d9tiejq69daie8f"
        AdminToken              = "4axaw8zxe0qm22wa6urpp5nskcne8z88cvbupv653y1njyi05h"
        AdminTokenUUID          = "zzzzz-gj3su-027z32aux8dg2s1"
        AnonymousToken          = "4kg6k6lzmp9kj4cpkcoxie964cmvjahbt4fod9zru44k4jqdmi"
@@ -30,6 +31,8 @@ const (
        UserAgreementPDH        = "b519d9cb706a29fc7ea24dbea2f05851+93"
        HelloWorldPdh           = "55713e6a34081eb03609e7ad5fcad129+62"
 
+       MultilevelCollection1 = "zzzzz-4zz18-pyw8yp9g3pr7irn"
+
        AProjectUUID    = "zzzzz-j7d0g-v955i6s2oi1cbso"
        ASubprojectUUID = "zzzzz-j7d0g-axqo7eu9pwvna1x"
 
index 9bdecdca1c40cfd2662197e39f4c129fc146932e..a52af804841fb58f4b837893ae83e3cb76d960b4 100644 (file)
@@ -131,8 +131,12 @@ type cachedPermission struct {
 }
 
 type cachedSession struct {
-       expire time.Time
-       fs     atomic.Value
+       expire        time.Time
+       fs            atomic.Value
+       client        *arvados.Client
+       arvadosclient *arvadosclient.ArvadosClient
+       keepclient    *keepclient.KeepClient
+       user          atomic.Value
 }
 
 func (c *cache) setup() {
@@ -213,7 +217,7 @@ func (c *cache) ResetSession(token string) {
 
 // Get a long-lived CustomFileSystem suitable for doing a read operation
 // with the given token.
-func (c *cache) GetSession(token string) (arvados.CustomFileSystem, error) {
+func (c *cache) GetSession(token string) (arvados.CustomFileSystem, *cachedSession, error) {
        c.setupOnce.Do(c.setup)
        now := time.Now()
        ent, _ := c.sessions.Get(token)
@@ -224,6 +228,17 @@ func (c *cache) GetSession(token string) (arvados.CustomFileSystem, error) {
                sess = &cachedSession{
                        expire: now.Add(c.config.TTL.Duration()),
                }
+               var err error
+               sess.client, err = arvados.NewClientFromConfig(c.cluster)
+               if err != nil {
+                       return nil, nil, err
+               }
+               sess.client.AuthToken = token
+               sess.arvadosclient, err = arvadosclient.New(sess.client)
+               if err != nil {
+                       return nil, nil, err
+               }
+               sess.keepclient = keepclient.New(sess.arvadosclient)
                c.sessions.Add(token, sess)
        } else if sess.expire.Before(now) {
                c.metrics.sessionMisses.Inc()
@@ -234,22 +249,12 @@ func (c *cache) GetSession(token string) (arvados.CustomFileSystem, error) {
        go c.pruneSessions()
        fs, _ := sess.fs.Load().(arvados.CustomFileSystem)
        if fs != nil && !expired {
-               return fs, nil
+               return fs, sess, nil
        }
-       ac, err := arvados.NewClientFromConfig(c.cluster)
-       if err != nil {
-               return nil, err
-       }
-       ac.AuthToken = token
-       arv, err := arvadosclient.New(ac)
-       if err != nil {
-               return nil, err
-       }
-       kc := keepclient.New(arv)
-       fs = ac.SiteFileSystem(kc)
+       fs = sess.client.SiteFileSystem(sess.keepclient)
        fs.ForwardSlashNameSubstitution(c.cluster.Collections.ForwardSlashNameSubstitution)
        sess.fs.Store(fs)
-       return fs, nil
+       return fs, sess, nil
 }
 
 // Remove all expired session cache entries, then remove more entries
@@ -464,3 +469,35 @@ func (c *cache) lookupCollection(key string) *arvados.Collection {
        c.metrics.collectionHits.Inc()
        return ent.collection
 }
+
+func (c *cache) GetTokenUser(token string) (*arvados.User, error) {
+       // Get and cache user record associated with this
+       // token.  We need to know their UUID for logging, and
+       // whether they are an admin or not for certain
+       // permission checks.
+
+       // Get/create session entry
+       _, sess, err := c.GetSession(token)
+       if err != nil {
+               return nil, err
+       }
+
+       // See if the user is already set, and if so, return it
+       user, _ := sess.user.Load().(*arvados.User)
+       if user != nil {
+               return user, nil
+       }
+
+       // Fetch the user record
+       c.metrics.apiCalls.Inc()
+       var current arvados.User
+
+       err = sess.client.RequestAndDecode(&current, "GET", "/arvados/v1/users/current", nil, nil)
+       if err != nil {
+               return nil, err
+       }
+
+       // Stash the user record for next time
+       sess.user.Store(&current)
+       return &current, nil
+}
index 81925421dc53b322fc9eb731422f85907e245fd5..97ec95e3aac3f96111ab49014635ae742073b4e8 100644 (file)
@@ -398,6 +398,7 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
        defer h.clientPool.Put(arv)
 
        var collection *arvados.Collection
+       var tokenUser *arvados.User
        tokenResult := make(map[string]int)
        for _, arv.ApiToken = range tokens {
                var err error
@@ -483,7 +484,17 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                return
        }
 
+       // Check configured permission
+       _, sess, err := h.Config.Cache.GetSession(arv.ApiToken)
+       tokenUser, err = h.Config.Cache.GetTokenUser(arv.ApiToken)
+
        if webdavMethod[r.Method] {
+               if !h.userPermittedToUploadOrDownload(r.Method, tokenUser) {
+                       http.Error(w, "Not permitted", http.StatusForbidden)
+                       return
+               }
+               h.logUploadOrDownload(r, sess.arvadosclient, nil, strings.Join(targetPath, "/"), collection, tokenUser)
+
                if writeMethod[r.Method] {
                        // Save the collection only if/when all
                        // webdav->filesystem operations succeed --
@@ -538,6 +549,12 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
        } else if stat.IsDir() {
                h.serveDirectory(w, r, collection.Name, fs, openPath, true)
        } else {
+               if !h.userPermittedToUploadOrDownload(r.Method, tokenUser) {
+                       http.Error(w, "Not permitted", http.StatusForbidden)
+                       return
+               }
+               h.logUploadOrDownload(r, sess.arvadosclient, nil, strings.Join(targetPath, "/"), collection, tokenUser)
+
                http.ServeContent(w, r, basename, stat.ModTime(), f)
                if wrote := int64(w.WroteBodyBytes()); wrote != stat.Size() && w.WroteStatus() == http.StatusOK {
                        // If we wrote fewer bytes than expected, it's
@@ -583,7 +600,8 @@ func (h *handler) serveSiteFS(w http.ResponseWriter, r *http.Request, tokens []s
                http.Error(w, errReadOnly.Error(), http.StatusMethodNotAllowed)
                return
        }
-       fs, err := h.Config.Cache.GetSession(tokens[0])
+
+       fs, sess, err := h.Config.Cache.GetSession(tokens[0])
        if err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                return
@@ -606,6 +624,14 @@ func (h *handler) serveSiteFS(w http.ResponseWriter, r *http.Request, tokens []s
                }
                return
        }
+
+       tokenUser, err := h.Config.Cache.GetTokenUser(tokens[0])
+       if !h.userPermittedToUploadOrDownload(r.Method, tokenUser) {
+               http.Error(w, "Not permitted", http.StatusForbidden)
+               return
+       }
+       h.logUploadOrDownload(r, sess.arvadosclient, fs, r.URL.Path, nil, tokenUser)
+
        if r.Method == "GET" {
                _, basename := filepath.Split(r.URL.Path)
                applyContentDispositionHdr(w, r, basename, attachment)
@@ -836,3 +862,117 @@ func (h *handler) seeOtherWithCookie(w http.ResponseWriter, r *http.Request, loc
        io.WriteString(w, html.EscapeString(redir))
        io.WriteString(w, `">Continue</A>`)
 }
+
+func (h *handler) userPermittedToUploadOrDownload(method string, tokenUser *arvados.User) bool {
+       var permitDownload bool
+       var permitUpload bool
+       if tokenUser != nil && tokenUser.IsAdmin {
+               permitUpload = h.Config.cluster.Collections.WebDAVPermission.Admin.Upload
+               permitDownload = h.Config.cluster.Collections.WebDAVPermission.Admin.Download
+       } else {
+               permitUpload = h.Config.cluster.Collections.WebDAVPermission.User.Upload
+               permitDownload = h.Config.cluster.Collections.WebDAVPermission.User.Download
+       }
+       if (method == "PUT" || method == "POST") && !permitUpload {
+               // Disallow operations that upload new files.
+               // Permit webdav operations that move existing files around.
+               return false
+       } else if method == "GET" && !permitDownload {
+               // Disallow downloading file contents.
+               // Permit webdav operations like PROPFIND that retrieve metadata
+               // but not file contents.
+               return false
+       }
+       return true
+}
+
+func (h *handler) logUploadOrDownload(
+       r *http.Request,
+       client *arvadosclient.ArvadosClient,
+       fs arvados.CustomFileSystem,
+       filepath string,
+       collection *arvados.Collection,
+       user *arvados.User) {
+
+       log := ctxlog.FromContext(r.Context())
+       props := make(map[string]string)
+       props["reqPath"] = r.URL.Path
+       var useruuid string
+       if user != nil {
+               log = log.WithField("user_uuid", user.UUID).
+                       WithField("user_full_name", user.FullName)
+               useruuid = user.UUID
+       } else {
+               useruuid = fmt.Sprintf("%s-tpzed-anonymouspublic", h.Config.cluster.ClusterID)
+       }
+       if collection == nil && fs != nil {
+               collection, filepath = h.determineCollection(fs, filepath)
+       }
+       if collection != nil {
+               log = log.WithField("collection_uuid", collection.UUID).
+                       WithField("collection_file_path", filepath)
+               props["collection_uuid"] = collection.UUID
+               props["collection_file_path"] = filepath
+       }
+       if r.Method == "PUT" || r.Method == "POST" {
+               log.Info("File upload")
+               if h.Config.cluster.Collections.WebDAVLogEvents {
+                       go func() {
+                               lr := arvadosclient.Dict{"log": arvadosclient.Dict{
+                                       "object_uuid": useruuid,
+                                       "event_type":  "file_upload",
+                                       "properties":  props}}
+                               err := client.Create("logs", lr, nil)
+                               if err != nil {
+                                       log.WithError(err).Error("Failed to create upload log event on API server")
+                               }
+                       }()
+               }
+       } else if r.Method == "GET" {
+               if collection != nil && collection.PortableDataHash != "" {
+                       log = log.WithField("portable_data_hash", collection.PortableDataHash)
+                       props["portable_data_hash"] = collection.PortableDataHash
+               }
+               log.Info("File download")
+               if h.Config.cluster.Collections.WebDAVLogEvents {
+                       go func() {
+                               lr := arvadosclient.Dict{"log": arvadosclient.Dict{
+                                       "object_uuid": useruuid,
+                                       "event_type":  "file_download",
+                                       "properties":  props}}
+                               err := client.Create("logs", lr, nil)
+                               if err != nil {
+                                       log.WithError(err).Error("Failed to create download log event on API server")
+                               }
+                       }()
+               }
+       }
+}
+
+func (h *handler) determineCollection(fs arvados.CustomFileSystem, path string) (*arvados.Collection, string) {
+       segments := strings.Split(path, "/")
+       var i int
+       for i = 0; i < len(segments); i++ {
+               dir := append([]string{}, segments[0:i]...)
+               dir = append(dir, ".arvados#collection")
+               f, err := fs.OpenFile(strings.Join(dir, "/"), os.O_RDONLY, 0)
+               if f != nil {
+                       defer f.Close()
+               }
+               if err != nil {
+                       if !os.IsNotExist(err) {
+                               return nil, ""
+                       }
+                       continue
+               }
+               // err is nil so we found it.
+               decoder := json.NewDecoder(f)
+               var collection arvados.Collection
+               err = decoder.Decode(&collection)
+               if err != nil {
+                       return nil, ""
+               }
+               return &collection, strings.Join(segments[i:], "/")
+       }
+       return nil, ""
+}
index 8715ab24f35c0312fcca8152dc464ab60aa582af..e883e806ccf509fc87a73f592300619e61901fd3 100644 (file)
@@ -9,6 +9,7 @@ import (
        "context"
        "fmt"
        "html"
+       "io"
        "io/ioutil"
        "net/http"
        "net/http/httptest"
@@ -92,8 +93,9 @@ func (s *UnitSuite) TestEmptyResponse(c *check.C) {
 
                // If we return no content because the client sent an
                // If-Modified-Since header, our response should be
-               // 304, and we should not emit a log message.
-               {true, true, http.StatusNotModified, ``},
+               // 304.  We still expect a "File download" log since it
+               // counts as a file access for auditing.
+               {true, true, http.StatusNotModified, `(?ms).*msg="File download".*`},
        } {
                c.Logf("trial: %+v", trial)
                arvadostest.StartKeep(2, true)
@@ -1185,3 +1187,187 @@ func copyHeader(h http.Header) http.Header {
        }
        return hc
 }
+
+func (s *IntegrationSuite) checkUploadDownloadRequest(c *check.C, h *handler, req *http.Request,
+       successCode int, direction string, perm bool, userUuid string, collectionUuid string, filepath string) {
+
+       client := s.testServer.Config.Client
+       client.AuthToken = arvadostest.AdminToken
+       var logentries arvados.LogList
+       limit1 := 1
+       err := client.RequestAndDecode(&logentries, "GET", "arvados/v1/logs", nil,
+               arvados.ResourceListParams{
+                       Limit: &limit1,
+                       Order: "created_at desc"})
+       c.Check(err, check.IsNil)
+       c.Check(logentries.Items, check.HasLen, 1)
+       lastLogId := logentries.Items[0].ID
+       nextLogId := lastLogId
+
+       var logbuf bytes.Buffer
+       logger := logrus.New()
+       logger.Out = &logbuf
+       resp := httptest.NewRecorder()
+       req = req.WithContext(ctxlog.Context(context.Background(), logger))
+       h.ServeHTTP(resp, req)
+
+       if perm {
+               c.Check(resp.Result().StatusCode, check.Equals, successCode)
+               c.Check(logbuf.String(), check.Matches, `(?ms).*msg="File `+direction+`".*`)
+               c.Check(logbuf.String(), check.Not(check.Matches), `(?ms).*level=error.*`)
+
+               count := 0
+               for ; nextLogId == lastLogId && count < 20; count++ {
+                       time.Sleep(50 * time.Millisecond)
+                       err = client.RequestAndDecode(&logentries, "GET", "arvados/v1/logs", nil,
+                               arvados.ResourceListParams{
+                                       Filters: []arvados.Filter{arvados.Filter{Attr: "event_type", Operator: "=", Operand: "file_" + direction}},
+                                       Limit:   &limit1,
+                                       Order:   "created_at desc",
+                               })
+                       c.Check(err, check.IsNil)
+                       if len(logentries.Items) > 0 {
+                               nextLogId = logentries.Items[0].ID
+                       }
+               }
+               c.Check(count, check.Not(check.Equals), 20)
+               c.Check(logentries.Items[0].ObjectUUID, check.Equals, userUuid)
+               c.Check(logentries.Items[0].Properties["collection_uuid"], check.Equals, collectionUuid)
+               c.Check(logentries.Items[0].Properties["collection_file_path"], check.Equals, filepath)
+       } else {
+               c.Check(resp.Result().StatusCode, check.Equals, http.StatusForbidden)
+               c.Check(logbuf.String(), check.Equals, "")
+       }
+}
+
+func (s *IntegrationSuite) TestDownloadLoggingPermission(c *check.C) {
+       config := newConfig(s.ArvConfig)
+       h := handler{Config: config}
+       u := mustParseURL("http://" + arvadostest.FooCollection + ".keep-web.example/foo")
+
+       config.cluster.Collections.TrustAllContent = true
+
+       for _, adminperm := range []bool{true, false} {
+               for _, userperm := range []bool{true, false} {
+                       config.cluster.Collections.WebDAVPermission.Admin.Download = adminperm
+                       config.cluster.Collections.WebDAVPermission.User.Download = userperm
+
+                       // Test admin permission
+                       req := &http.Request{
+                               Method:     "GET",
+                               Host:       u.Host,
+                               URL:        u,
+                               RequestURI: u.RequestURI(),
+                               Header: http.Header{
+                                       "Authorization": {"Bearer " + arvadostest.AdminToken},
+                               },
+                       }
+                       s.checkUploadDownloadRequest(c, &h, req, http.StatusOK, "download", adminperm,
+                               arvadostest.AdminUserUUID, arvadostest.FooCollection, "foo")
+
+                       // Test user permission
+                       req = &http.Request{
+                               Method:     "GET",
+                               Host:       u.Host,
+                               URL:        u,
+                               RequestURI: u.RequestURI(),
+                               Header: http.Header{
+                                       "Authorization": {"Bearer " + arvadostest.ActiveToken},
+                               },
+                       }
+                       s.checkUploadDownloadRequest(c, &h, req, http.StatusOK, "download", userperm,
+                               arvadostest.ActiveUserUUID, arvadostest.FooCollection, "foo")
+               }
+       }
+
+       config.cluster.Collections.WebDAVPermission.User.Download = true
+
+       for _, tryurl := range []string{"http://" + arvadostest.MultilevelCollection1 + ".keep-web.example/dir1/subdir/file1",
+               "http://keep-web/users/active/multilevel_collection_1/dir1/subdir/file1"} {
+
+               u = mustParseURL(tryurl)
+               req := &http.Request{
+                       Method:     "GET",
+                       Host:       u.Host,
+                       URL:        u,
+                       RequestURI: u.RequestURI(),
+                       Header: http.Header{
+                               "Authorization": {"Bearer " + arvadostest.ActiveToken},
+                       },
+               }
+               s.checkUploadDownloadRequest(c, &h, req, http.StatusOK, "download", true,
+                       arvadostest.ActiveUserUUID, arvadostest.MultilevelCollection1, "dir1/subdir/file1")
+       }
+
+       u = mustParseURL("http://" + strings.Replace(arvadostest.FooCollectionPDH, "+", "-", 1) + ".keep-web.example/foo")
+       req := &http.Request{
+               Method:     "GET",
+               Host:       u.Host,
+               URL:        u,
+               RequestURI: u.RequestURI(),
+               Header: http.Header{
+                       "Authorization": {"Bearer " + arvadostest.ActiveToken},
+               },
+       }
+       s.checkUploadDownloadRequest(c, &h, req, http.StatusOK, "download", true,
+               arvadostest.ActiveUserUUID, arvadostest.FooCollection, "foo")
+}
+
+func (s *IntegrationSuite) TestUploadLoggingPermission(c *check.C) {
+       config := newConfig(s.ArvConfig)
+       h := handler{Config: config}
+
+       for _, adminperm := range []bool{true, false} {
+               for _, userperm := range []bool{true, false} {
+
+                       arv := s.testServer.Config.Client
+                       arv.AuthToken = arvadostest.ActiveToken
+
+                       var coll arvados.Collection
+                       err := arv.RequestAndDecode(&coll,
+                               "POST",
+                               "/arvados/v1/collections",
+                               nil,
+                               map[string]interface{}{
+                                       "ensure_unique_name": true,
+                                       "collection": map[string]interface{}{
+                                               "name": "test collection",
+                                       },
+                               })
+                       c.Assert(err, check.Equals, nil)
+
+                       u := mustParseURL("http://" + coll.UUID + ".keep-web.example/bar")
+
+                       config.cluster.Collections.WebDAVPermission.Admin.Upload = adminperm
+                       config.cluster.Collections.WebDAVPermission.User.Upload = userperm
+
+                       // Test admin permission
+                       req := &http.Request{
+                               Method:     "PUT",
+                               Host:       u.Host,
+                               URL:        u,
+                               RequestURI: u.RequestURI(),
+                               Header: http.Header{
+                                       "Authorization": {"Bearer " + arvadostest.AdminToken},
+                               },
+                               Body: io.NopCloser(bytes.NewReader([]byte("bar"))),
+                       }
+                       s.checkUploadDownloadRequest(c, &h, req, http.StatusCreated, "upload", adminperm,
+                               arvadostest.AdminUserUUID, coll.UUID, "bar")
+
+                       // Test user permission
+                       req = &http.Request{
+                               Method:     "PUT",
+                               Host:       u.Host,
+                               URL:        u,
+                               RequestURI: u.RequestURI(),
+                               Header: http.Header{
+                                       "Authorization": {"Bearer " + arvadostest.ActiveToken},
+                               },
+                               Body: io.NopCloser(bytes.NewReader([]byte("bar"))),
+                       }
+                       s.checkUploadDownloadRequest(c, &h, req, http.StatusCreated, "upload", userperm,
+                               arvadostest.ActiveUserUUID, coll.UUID, "bar")
+               }
+       }
+}
index 6ea9bf9f7a8383cc10ed82e108b76bb3ca97585b..e6262374d640f9ed526ef38c816fa785bce1d3d5 100644 (file)
@@ -24,7 +24,9 @@ import (
        "time"
 
        "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadosclient"
        "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "git.arvados.org/arvados.git/sdk/go/keepclient"
        "github.com/AdRoll/goamz/s3"
 )
 
@@ -309,19 +311,25 @@ func (h *handler) serveS3(w http.ResponseWriter, r *http.Request) bool {
 
        var err error
        var fs arvados.CustomFileSystem
+       var arvclient *arvadosclient.ArvadosClient
        if r.Method == http.MethodGet || r.Method == http.MethodHead {
                // Use a single session (cached FileSystem) across
                // multiple read requests.
-               fs, err = h.Config.Cache.GetSession(token)
+               var sess *cachedSession
+               fs, sess, err = h.Config.Cache.GetSession(token)
                if err != nil {
                        s3ErrorResponse(w, InternalError, err.Error(), r.URL.Path, http.StatusInternalServerError)
                        return true
                }
+               arvclient = sess.arvadosclient
        } else {
                // Create a FileSystem for this request, to avoid
                // exposing incomplete write operations to concurrent
                // requests.
-               _, kc, client, release, err := h.getClients(r.Header.Get("X-Request-Id"), token)
+               var kc *keepclient.KeepClient
+               var release func()
+               var client *arvados.Client
+               arvclient, kc, client, release, err = h.getClients(r.Header.Get("X-Request-Id"), token)
                if err != nil {
                        s3ErrorResponse(w, InternalError, err.Error(), r.URL.Path, http.StatusInternalServerError)
                        return true
@@ -396,6 +404,14 @@ func (h *handler) serveS3(w http.ResponseWriter, r *http.Request) bool {
                        s3ErrorResponse(w, NoSuchKey, "The specified key does not exist.", r.URL.Path, http.StatusNotFound)
                        return true
                }
+
+               tokenUser, err := h.Config.Cache.GetTokenUser(token)
+               if !h.userPermittedToUploadOrDownload(r.Method, tokenUser) {
+                       http.Error(w, "Not permitted", http.StatusForbidden)
+                       return true
+               }
+               h.logUploadOrDownload(r, arvclient, fs, fspath, nil, tokenUser)
+
                // shallow copy r, and change URL path
                r := *r
                r.URL.Path = fspath
@@ -479,6 +495,14 @@ func (h *handler) serveS3(w http.ResponseWriter, r *http.Request) bool {
                                return true
                        }
                        defer f.Close()
+
+                       tokenUser, err := h.Config.Cache.GetTokenUser(token)
+                       if !h.userPermittedToUploadOrDownload(r.Method, tokenUser) {
+                               http.Error(w, "Not permitted", http.StatusForbidden)
+                               return true
+                       }
+                       h.logUploadOrDownload(r, arvclient, fs, fspath, nil, tokenUser)
+
                        _, err = io.Copy(f, r.Body)
                        if err != nil {
                                err = fmt.Errorf("write to %q failed: %w", r.URL.Path, err)
index 5c68eb4249d0a7da7c4ea04717d8295041d29cd0..a65a48892ae75d709ae578e3354fe58803bddac1 100644 (file)
@@ -34,6 +34,7 @@ var _ = check.Suite(&IntegrationSuite{})
 // IntegrationSuite tests need an API server and a keep-web server
 type IntegrationSuite struct {
        testServer *server
+       ArvConfig  *arvados.Config
 }
 
 func (s *IntegrationSuite) TestNoToken(c *check.C) {
@@ -389,7 +390,7 @@ func (s *IntegrationSuite) TestMetrics(c *check.C) {
        c.Check(summaries["request_duration_seconds/get/404"].SampleCount, check.Equals, "1")
        c.Check(summaries["time_to_status_seconds/get/404"].SampleCount, check.Equals, "1")
        c.Check(counters["arvados_keepweb_collectioncache_requests//"].Value, check.Equals, int64(2))
-       c.Check(counters["arvados_keepweb_collectioncache_api_calls//"].Value, check.Equals, int64(1))
+       c.Check(counters["arvados_keepweb_collectioncache_api_calls//"].Value, check.Equals, int64(2))
        c.Check(counters["arvados_keepweb_collectioncache_hits//"].Value, check.Equals, int64(1))
        c.Check(counters["arvados_keepweb_collectioncache_pdh_hits//"].Value, check.Equals, int64(1))
        c.Check(counters["arvados_keepweb_collectioncache_permission_hits//"].Value, check.Equals, int64(1))
@@ -446,6 +447,7 @@ func (s *IntegrationSuite) SetUpTest(c *check.C) {
        cfg.cluster.ManagementToken = arvadostest.ManagementToken
        cfg.cluster.SystemRootToken = arvadostest.SystemRootToken
        cfg.cluster.Users.AnonymousUserToken = arvadostest.AnonymousToken
+       s.ArvConfig = arvCfg
        s.testServer = &server{Config: cfg}
        err = s.testServer.Start(ctxlog.TestLogger(c))
        c.Assert(err, check.Equals, nil)
index 3191a373f23ce5f1f4e7d7fa6c7edd5463be8f4e..c679e5b91cff8f9b86daa0b8ccda60aa20b36f96 100644 (file)
@@ -16,7 +16,6 @@ import (
        "os/signal"
        "regexp"
        "strings"
-       "sync"
        "syscall"
        "time"
 
@@ -29,6 +28,7 @@ import (
        "github.com/coreos/go-systemd/daemon"
        "github.com/ghodss/yaml"
        "github.com/gorilla/mux"
+       lru "github.com/hashicorp/golang-lru"
        log "github.com/sirupsen/logrus"
 )
 
@@ -163,45 +163,53 @@ func run(logger log.FieldLogger, cluster *arvados.Cluster) error {
        signal.Notify(term, syscall.SIGINT)
 
        // Start serving requests.
-       router = MakeRESTRouter(kc, time.Duration(keepclient.DefaultProxyRequestTimeout), cluster.ManagementToken)
+       router, err = MakeRESTRouter(kc, time.Duration(keepclient.DefaultProxyRequestTimeout), cluster, logger)
+       if err != nil {
+               return err
+       }
        return http.Serve(listener, httpserver.AddRequestIDs(httpserver.LogRequests(router)))
 }
 
+type TokenCacheEntry struct {
+       expire int64
+       user   *arvados.User
+}
+
 type APITokenCache struct {
-       tokens     map[string]int64
-       lock       sync.Mutex
+       tokens     *lru.TwoQueueCache
        expireTime int64
 }
 
-// RememberToken caches the token and set an expire time.  If we already have
-// an expire time on the token, it is not updated.
-func (cache *APITokenCache) RememberToken(token string) {
-       cache.lock.Lock()
-       defer cache.lock.Unlock()
-
+// RememberToken caches the token and set an expire time.  If the
+// token is already in the cache, it is not updated.
+func (cache *APITokenCache) RememberToken(token string, user *arvados.User) {
        now := time.Now().Unix()
-       if cache.tokens[token] == 0 {
-               cache.tokens[token] = now + cache.expireTime
+       _, ok := cache.tokens.Get(token)
+       if !ok {
+               cache.tokens.Add(token, TokenCacheEntry{
+                       expire: now + cache.expireTime,
+                       user:   user,
+               })
        }
 }
 
 // RecallToken checks if the cached token is known and still believed to be
 // valid.
-func (cache *APITokenCache) RecallToken(token string) bool {
-       cache.lock.Lock()
-       defer cache.lock.Unlock()
+func (cache *APITokenCache) RecallToken(token string) (bool, *arvados.User) {
+       val, ok := cache.tokens.Get(token)
+       if !ok {
+               return false, nil
+       }
 
+       cacheEntry := val.(TokenCacheEntry)
        now := time.Now().Unix()
-       if cache.tokens[token] == 0 {
-               // Unknown token
-               return false
-       } else if now < cache.tokens[token] {
+       if now < cacheEntry.expire {
                // Token is known and still valid
-               return true
+               return true, cacheEntry.user
        } else {
                // Token is expired
-               cache.tokens[token] = 0
-               return false
+               cache.tokens.Remove(token)
+               return false, nil
        }
 }
 
@@ -216,10 +224,10 @@ func GetRemoteAddress(req *http.Request) string {
        return req.RemoteAddr
 }
 
-func CheckAuthorizationHeader(kc *keepclient.KeepClient, cache *APITokenCache, req *http.Request) (pass bool, tok string) {
+func (h *proxyHandler) CheckAuthorizationHeader(req *http.Request) (pass bool, tok string, user *arvados.User) {
        parts := strings.SplitN(req.Header.Get("Authorization"), " ", 2)
        if len(parts) < 2 || !(parts[0] == "OAuth2" || parts[0] == "Bearer") || len(parts[1]) == 0 {
-               return false, ""
+               return false, "", nil
        }
        tok = parts[1]
 
@@ -234,29 +242,56 @@ func CheckAuthorizationHeader(kc *keepclient.KeepClient, cache *APITokenCache, r
                op = "write"
        }
 
-       if cache.RecallToken(op + ":" + tok) {
+       if ok, user := h.APITokenCache.RecallToken(op + ":" + tok); ok {
                // Valid in the cache, short circuit
-               return true, tok
+               return true, tok, user
        }
 
        var err error
-       arv := *kc.Arvados
+       arv := *h.KeepClient.Arvados
        arv.ApiToken = tok
        arv.RequestID = req.Header.Get("X-Request-Id")
-       if op == "read" {
-               err = arv.Call("HEAD", "keep_services", "", "accessible", nil, nil)
-       } else {
-               err = arv.Call("HEAD", "users", "", "current", nil, nil)
+       user = &arvados.User{}
+       userCurrentError := arv.Call("GET", "users", "", "current", nil, user)
+       err = userCurrentError
+       if err != nil && op == "read" {
+               apiError, ok := err.(arvadosclient.APIServerError)
+               if ok && apiError.HttpStatusCode == http.StatusForbidden {
+                       // If it was a scoped "sharing" token it will
+                       // return 403 instead of 401 for the current
+                       // user check.  If it is a download operation
+                       // and they have permission to read the
+                       // keep_services table, we can allow it.
+                       err = arv.Call("HEAD", "keep_services", "", "accessible", nil, nil)
+               }
        }
        if err != nil {
                log.Printf("%s: CheckAuthorizationHeader error: %v", GetRemoteAddress(req), err)
-               return false, ""
+               return false, "", nil
+       }
+
+       if userCurrentError == nil && user.IsAdmin {
+               // checking userCurrentError is probably redundant,
+               // IsAdmin would be false anyway. But can't hurt.
+               if op == "read" && !h.cluster.Collections.KeepproxyPermission.Admin.Download {
+                       return false, "", nil
+               }
+               if op == "write" && !h.cluster.Collections.KeepproxyPermission.Admin.Upload {
+                       return false, "", nil
+               }
+       } else {
+               if op == "read" && !h.cluster.Collections.KeepproxyPermission.User.Download {
+                       return false, "", nil
+               }
+               if op == "write" && !h.cluster.Collections.KeepproxyPermission.User.Upload {
+                       return false, "", nil
+               }
        }
 
        // Success!  Update cache
-       cache.RememberToken(op + ":" + tok)
+       h.APITokenCache.RememberToken(op+":"+tok, user)
 
-       return true, tok
+       return true, tok, user
 }
 
 // We need to make a private copy of the default http transport early
@@ -273,11 +308,13 @@ type proxyHandler struct {
        *APITokenCache
        timeout   time.Duration
        transport *http.Transport
+       logger    log.FieldLogger
+       cluster   *arvados.Cluster
 }
 
 // MakeRESTRouter returns an http.Handler that passes GET and PUT
 // requests to the appropriate handlers.
-func MakeRESTRouter(kc *keepclient.KeepClient, timeout time.Duration, mgmtToken string) http.Handler {
+func MakeRESTRouter(kc *keepclient.KeepClient, timeout time.Duration, cluster *arvados.Cluster, logger log.FieldLogger) (http.Handler, error) {
        rest := mux.NewRouter()
 
        transport := defaultTransport
@@ -289,15 +326,22 @@ func MakeRESTRouter(kc *keepclient.KeepClient, timeout time.Duration, mgmtToken
        transport.TLSClientConfig = arvadosclient.MakeTLSConfig(kc.Arvados.ApiInsecure)
        transport.TLSHandshakeTimeout = keepclient.DefaultTLSHandshakeTimeout
 
+       cacheQ, err := lru.New2Q(500)
+       if err != nil {
+               return nil, fmt.Errorf("Error from lru.New2Q: %v", err)
+       }
+
        h := &proxyHandler{
                Handler:    rest,
                KeepClient: kc,
                timeout:    timeout,
                transport:  &transport,
                APITokenCache: &APITokenCache{
-                       tokens:     make(map[string]int64),
+                       tokens:     cacheQ,
                        expireTime: 300,
                },
+               logger:  logger,
+               cluster: cluster,
        }
 
        rest.HandleFunc(`/{locator:[0-9a-f]{32}\+.*}`, h.Get).Methods("GET", "HEAD")
@@ -316,19 +360,19 @@ func MakeRESTRouter(kc *keepclient.KeepClient, timeout time.Duration, mgmtToken
        rest.HandleFunc(`/`, h.Options).Methods("OPTIONS")
 
        rest.Handle("/_health/{check}", &health.Handler{
-               Token:  mgmtToken,
+               Token:  cluster.ManagementToken,
                Prefix: "/_health/",
        }).Methods("GET")
 
        rest.NotFoundHandler = InvalidPathHandler{}
-       return h
+       return h, nil
 }
 
 var errLoopDetected = errors.New("loop detected")
 
-func (*proxyHandler) checkLoop(resp http.ResponseWriter, req *http.Request) error {
+func (*proxyHandler) checkLoop(resp http.ResponseWriter, req *http.Request) error {
        if via := req.Header.Get("Via"); strings.Index(via, " "+viaAlias) >= 0 {
-               log.Printf("proxy loop detected (request has Via: %q): perhaps keepproxy is misidentified by gateway config as an external client, or its keep_services record does not have service_type=proxy?", via)
+               h.logger.Printf("proxy loop detected (request has Via: %q): perhaps keepproxy is misidentified by gateway config as an external client, or its keep_services record does not have service_type=proxy?", via)
                http.Error(resp, errLoopDetected.Error(), http.StatusInternalServerError)
                return errLoopDetected
        }
@@ -354,7 +398,7 @@ func (h *proxyHandler) Options(resp http.ResponseWriter, req *http.Request) {
        SetCorsHeaders(resp)
 }
 
-var errBadAuthorizationHeader = errors.New("Missing or invalid Authorization header")
+var errBadAuthorizationHeader = errors.New("Missing or invalid Authorization header, or method not allowed")
 var errContentLengthMismatch = errors.New("Actual length != expected content length")
 var errMethodNotSupported = errors.New("Method not supported")
 
@@ -384,7 +428,8 @@ func (h *proxyHandler) Get(resp http.ResponseWriter, req *http.Request) {
 
        var pass bool
        var tok string
-       if pass, tok = CheckAuthorizationHeader(kc, h.APITokenCache, req); !pass {
+       var user *arvados.User
+       if pass, tok, user = h.CheckAuthorizationHeader(req); !pass {
                status, err = http.StatusForbidden, errBadAuthorizationHeader
                return
        }
@@ -398,6 +443,18 @@ func (h *proxyHandler) Get(resp http.ResponseWriter, req *http.Request) {
 
        locator = removeHint.ReplaceAllString(locator, "$1")
 
+       if locator != "" {
+               parts := strings.SplitN(locator, "+", 3)
+               if len(parts) >= 2 {
+                       logger := h.logger
+                       if user != nil {
+                               logger = logger.WithField("user_uuid", user.UUID).
+                                       WithField("user_full_name", user.FullName)
+                       }
+                       logger.WithField("locator", fmt.Sprintf("%s+%s", parts[0], parts[1])).Infof("Block download")
+               }
+       }
+
        switch req.Method {
        case "HEAD":
                expectLength, proxiedURI, err = kc.Ask(locator)
@@ -498,7 +555,8 @@ func (h *proxyHandler) Put(resp http.ResponseWriter, req *http.Request) {
 
        var pass bool
        var tok string
-       if pass, tok = CheckAuthorizationHeader(kc, h.APITokenCache, req); !pass {
+       var user *arvados.User
+       if pass, tok, user = h.CheckAuthorizationHeader(req); !pass {
                err = errBadAuthorizationHeader
                status = http.StatusForbidden
                return
@@ -531,6 +589,18 @@ func (h *proxyHandler) Put(resp http.ResponseWriter, req *http.Request) {
                locatorOut, wroteReplicas, err = kc.PutHR(locatorIn, req.Body, expectLength)
        }
 
+       if locatorOut != "" {
+               parts := strings.SplitN(locatorOut, "+", 3)
+               if len(parts) >= 2 {
+                       logger := h.logger
+                       if user != nil {
+                               logger = logger.WithField("user_uuid", user.UUID).
+                                       WithField("user_full_name", user.FullName)
+                       }
+                       logger.WithField("locator", fmt.Sprintf("%s+%s", parts[0], parts[1])).Infof("Block upload")
+               }
+       }
+
        // Tell the client how many successful PUTs we accomplished
        resp.Header().Set(keepclient.XKeepReplicasStored, fmt.Sprintf("%d", wroteReplicas))
 
@@ -585,7 +655,7 @@ func (h *proxyHandler) Index(resp http.ResponseWriter, req *http.Request) {
        }()
 
        kc := h.makeKeepClient(req)
-       ok, token := CheckAuthorizationHeader(kc, h.APITokenCache, req)
+       ok, token, _ := h.CheckAuthorizationHeader(req)
        if !ok {
                status, err = http.StatusForbidden, errBadAuthorizationHeader
                return
index c569a05e74d970efa98248b7f9ca95ff14657fdb..2d4266d8d591ca1f304f0ca1a18993d20348e8be 100644 (file)
@@ -26,6 +26,7 @@ import (
        "git.arvados.org/arvados.git/sdk/go/keepclient"
        log "github.com/sirupsen/logrus"
 
+       "gopkg.in/check.v1"
        . "gopkg.in/check.v1"
 )
 
@@ -120,7 +121,7 @@ func (s *NoKeepServerSuite) TearDownSuite(c *C) {
        arvadostest.StopAPI()
 }
 
-func runProxy(c *C, bogusClientToken bool, loadKeepstoresFromConfig bool) *keepclient.KeepClient {
+func runProxy(c *C, bogusClientToken bool, loadKeepstoresFromConfig bool, kp *arvados.UploadDownloadRolePermissions) (*keepclient.KeepClient, *bytes.Buffer) {
        cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
        c.Assert(err, Equals, nil)
        cluster, err := cfg.GetCluster("")
@@ -133,9 +134,16 @@ func runProxy(c *C, bogusClientToken bool, loadKeepstoresFromConfig bool) *keepc
 
        cluster.Services.Keepproxy.InternalURLs = map[arvados.URL]arvados.ServiceInstance{{Host: ":0"}: {}}
 
+       if kp != nil {
+               cluster.Collections.KeepproxyPermission = *kp
+       }
+
        listener = nil
+       logbuf := &bytes.Buffer{}
+       logger := log.New()
+       logger.Out = logbuf
        go func() {
-               run(log.New(), cluster)
+               run(logger, cluster)
                defer closeListener()
        }()
        waitForListener()
@@ -153,11 +161,11 @@ func runProxy(c *C, bogusClientToken bool, loadKeepstoresFromConfig bool) *keepc
        kc.SetServiceRoots(sr, sr, sr)
        kc.Arvados.External = true
 
-       return kc
+       return kc, logbuf
 }
 
 func (s *ServerRequiredSuite) TestResponseViaHeader(c *C) {
-       runProxy(c, false, false)
+       runProxy(c, false, false, nil)
        defer closeListener()
 
        req, err := http.NewRequest("POST",
@@ -184,7 +192,7 @@ func (s *ServerRequiredSuite) TestResponseViaHeader(c *C) {
 }
 
 func (s *ServerRequiredSuite) TestLoopDetection(c *C) {
-       kc := runProxy(c, false, false)
+       kc, _ := runProxy(c, false, false, nil)
        defer closeListener()
 
        sr := map[string]string{
@@ -202,7 +210,7 @@ func (s *ServerRequiredSuite) TestLoopDetection(c *C) {
 }
 
 func (s *ServerRequiredSuite) TestStorageClassesHeader(c *C) {
-       kc := runProxy(c, false, false)
+       kc, _ := runProxy(c, false, false, nil)
        defer closeListener()
 
        // Set up fake keepstore to record request headers
@@ -251,7 +259,7 @@ func (s *ServerRequiredSuite) TestStorageClassesConfirmedHeader(c *C) {
 }
 
 func (s *ServerRequiredSuite) TestDesiredReplicas(c *C) {
-       kc := runProxy(c, false, false)
+       kc, _ := runProxy(c, false, false, nil)
        defer closeListener()
 
        content := []byte("TestDesiredReplicas")
@@ -268,7 +276,7 @@ func (s *ServerRequiredSuite) TestDesiredReplicas(c *C) {
 }
 
 func (s *ServerRequiredSuite) TestPutWrongContentLength(c *C) {
-       kc := runProxy(c, false, false)
+       kc, _ := runProxy(c, false, false, nil)
        defer closeListener()
 
        content := []byte("TestPutWrongContentLength")
@@ -279,7 +287,8 @@ func (s *ServerRequiredSuite) TestPutWrongContentLength(c *C) {
        // fixes the invalid Content-Length header. In order to test
        // our server behavior, we have to call the handler directly
        // using an httptest.ResponseRecorder.
-       rtr := MakeRESTRouter(kc, 10*time.Second, "")
+       rtr, err := MakeRESTRouter(kc, 10*time.Second, &arvados.Cluster{}, log.New())
+       c.Assert(err, check.IsNil)
 
        type testcase struct {
                sendLength   string
@@ -307,7 +316,7 @@ func (s *ServerRequiredSuite) TestPutWrongContentLength(c *C) {
 }
 
 func (s *ServerRequiredSuite) TestManyFailedPuts(c *C) {
-       kc := runProxy(c, false, false)
+       kc, _ := runProxy(c, false, false, nil)
        defer closeListener()
        router.(*proxyHandler).timeout = time.Nanosecond
 
@@ -334,7 +343,7 @@ func (s *ServerRequiredSuite) TestManyFailedPuts(c *C) {
 }
 
 func (s *ServerRequiredSuite) TestPutAskGet(c *C) {
-       kc := runProxy(c, false, false)
+       kc, logbuf := runProxy(c, false, false, nil)
        defer closeListener()
 
        hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
@@ -370,6 +379,9 @@ func (s *ServerRequiredSuite) TestPutAskGet(c *C) {
                c.Check(rep, Equals, 2)
                c.Check(err, Equals, nil)
                c.Log("Finished PutB (expected success)")
+
+               c.Check(logbuf.String(), Matches, `(?ms).*msg="Block upload" locator=acbd18db4cc2f85cedef654fccc4a4d8\+3 user_full_name="TestCase Administrator" user_uuid=zzzzz-tpzed-d9tiejq69daie8f.*`)
+               logbuf.Reset()
        }
 
        {
@@ -377,6 +389,8 @@ func (s *ServerRequiredSuite) TestPutAskGet(c *C) {
                c.Assert(err, Equals, nil)
                c.Check(blocklen, Equals, int64(3))
                c.Log("Finished Ask (expected success)")
+               c.Check(logbuf.String(), Matches, `(?ms).*msg="Block download" locator=acbd18db4cc2f85cedef654fccc4a4d8\+3 user_full_name="TestCase Administrator" user_uuid=zzzzz-tpzed-d9tiejq69daie8f.*`)
+               logbuf.Reset()
        }
 
        {
@@ -387,6 +401,8 @@ func (s *ServerRequiredSuite) TestPutAskGet(c *C) {
                c.Check(all, DeepEquals, []byte("foo"))
                c.Check(blocklen, Equals, int64(3))
                c.Log("Finished Get (expected success)")
+               c.Check(logbuf.String(), Matches, `(?ms).*msg="Block download" locator=acbd18db4cc2f85cedef654fccc4a4d8\+3 user_full_name="TestCase Administrator" user_uuid=zzzzz-tpzed-d9tiejq69daie8f.*`)
+               logbuf.Reset()
        }
 
        {
@@ -411,7 +427,7 @@ func (s *ServerRequiredSuite) TestPutAskGet(c *C) {
 }
 
 func (s *ServerRequiredSuite) TestPutAskGetForbidden(c *C) {
-       kc := runProxy(c, true, false)
+       kc, _ := runProxy(c, true, false, nil)
        defer closeListener()
 
        hash := fmt.Sprintf("%x+3", md5.Sum([]byte("bar")))
@@ -426,18 +442,116 @@ func (s *ServerRequiredSuite) TestPutAskGetForbidden(c *C) {
 
        blocklen, _, err := kc.Ask(hash)
        c.Check(err, FitsTypeOf, &keepclient.ErrNotFound{})
-       c.Check(err, ErrorMatches, ".*not found.*")
+       c.Check(err, ErrorMatches, ".*HTTP 403.*")
        c.Check(blocklen, Equals, int64(0))
 
        _, blocklen, _, err = kc.Get(hash)
        c.Check(err, FitsTypeOf, &keepclient.ErrNotFound{})
-       c.Check(err, ErrorMatches, ".*not found.*")
+       c.Check(err, ErrorMatches, ".*HTTP 403.*")
        c.Check(blocklen, Equals, int64(0))
+}
+
+func testPermission(c *C, admin bool, perm arvados.UploadDownloadPermission) {
+       kp := arvados.UploadDownloadRolePermissions{}
+       if admin {
+               kp.Admin = perm
+               kp.User = arvados.UploadDownloadPermission{Upload: true, Download: true}
+       } else {
+               kp.Admin = arvados.UploadDownloadPermission{Upload: true, Download: true}
+               kp.User = perm
+       }
+
+       kc, logbuf := runProxy(c, false, false, &kp)
+       defer closeListener()
+       if admin {
+               kc.Arvados.ApiToken = arvadostest.AdminToken
+       } else {
+               kc.Arvados.ApiToken = arvadostest.ActiveToken
+       }
+
+       hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
+       var hash2 string
+
+       {
+               var rep int
+               var err error
+               hash2, rep, err = kc.PutB([]byte("foo"))
+
+               if perm.Upload {
+                       c.Check(hash2, Matches, fmt.Sprintf(`^%s\+3(\+.+)?$`, hash))
+                       c.Check(rep, Equals, 2)
+                       c.Check(err, Equals, nil)
+                       c.Log("Finished PutB (expected success)")
+                       if admin {
+                               c.Check(logbuf.String(), Matches, `(?ms).*msg="Block upload" locator=acbd18db4cc2f85cedef654fccc4a4d8\+3 user_full_name="TestCase Administrator" user_uuid=zzzzz-tpzed-d9tiejq69daie8f.*`)
+                       } else {
+
+                               c.Check(logbuf.String(), Matches, `(?ms).*msg="Block upload" locator=acbd18db4cc2f85cedef654fccc4a4d8\+3 user_full_name="Active User" user_uuid=zzzzz-tpzed-xurymjxw79nv3jz.*`)
+                       }
+               } else {
+                       c.Check(hash2, Equals, "")
+                       c.Check(rep, Equals, 0)
+                       c.Check(err, FitsTypeOf, keepclient.InsufficientReplicasError(errors.New("")))
+               }
+               logbuf.Reset()
+       }
+       if perm.Upload {
+               // can't test download without upload.
+
+               reader, blocklen, _, err := kc.Get(hash2)
+               if perm.Download {
+                       c.Assert(err, Equals, nil)
+                       all, err := ioutil.ReadAll(reader)
+                       c.Check(err, IsNil)
+                       c.Check(all, DeepEquals, []byte("foo"))
+                       c.Check(blocklen, Equals, int64(3))
+                       c.Log("Finished Get (expected success)")
+                       if admin {
+                               c.Check(logbuf.String(), Matches, `(?ms).*msg="Block download" locator=acbd18db4cc2f85cedef654fccc4a4d8\+3 user_full_name="TestCase Administrator" user_uuid=zzzzz-tpzed-d9tiejq69daie8f.*`)
+                       } else {
+                               c.Check(logbuf.String(), Matches, `(?ms).*msg="Block download" locator=acbd18db4cc2f85cedef654fccc4a4d8\+3 user_full_name="Active User" user_uuid=zzzzz-tpzed-xurymjxw79nv3jz.*`)
+                       }
+               } else {
+                       c.Check(err, FitsTypeOf, &keepclient.ErrNotFound{})
+                       c.Check(err, ErrorMatches, ".*Missing or invalid Authorization header, or method not allowed.*")
+                       c.Check(blocklen, Equals, int64(0))
+               }
+               logbuf.Reset()
+       }
 
 }
 
+func (s *ServerRequiredSuite) TestPutGetPermission(c *C) {
+
+       for _, adminperm := range []bool{true, false} {
+               for _, userperm := range []bool{true, false} {
+
+                       testPermission(c, true,
+                               arvados.UploadDownloadPermission{
+                                       Upload:   adminperm,
+                                       Download: true,
+                               })
+                       testPermission(c, true,
+                               arvados.UploadDownloadPermission{
+                                       Upload:   true,
+                                       Download: adminperm,
+                               })
+                       testPermission(c, false,
+                               arvados.UploadDownloadPermission{
+                                       Upload:   true,
+                                       Download: userperm,
+                               })
+                       testPermission(c, false,
+                               arvados.UploadDownloadPermission{
+                                       Upload:   true,
+                                       Download: userperm,
+                               })
+               }
+       }
+}
+
 func (s *ServerRequiredSuite) TestCorsHeaders(c *C) {
-       runProxy(c, false, false)
+       runProxy(c, false, false, nil)
        defer closeListener()
 
        {
@@ -468,7 +582,7 @@ func (s *ServerRequiredSuite) TestCorsHeaders(c *C) {
 }
 
 func (s *ServerRequiredSuite) TestPostWithoutHash(c *C) {
-       runProxy(c, false, false)
+       runProxy(c, false, false, nil)
        defer closeListener()
 
        {
@@ -526,7 +640,7 @@ func (s *ServerRequiredConfigYmlSuite) TestGetIndex(c *C) {
 }
 
 func getIndexWorker(c *C, useConfig bool) {
-       kc := runProxy(c, false, useConfig)
+       kc, _ := runProxy(c, false, useConfig, nil)
        defer closeListener()
 
        // Put "index-data" blocks
@@ -589,7 +703,7 @@ func getIndexWorker(c *C, useConfig bool) {
 }
 
 func (s *ServerRequiredSuite) TestCollectionSharingToken(c *C) {
-       kc := runProxy(c, false, false)
+       kc, _ := runProxy(c, false, false, nil)
        defer closeListener()
        hash, _, err := kc.PutB([]byte("shareddata"))
        c.Check(err, IsNil)
@@ -602,7 +716,7 @@ func (s *ServerRequiredSuite) TestCollectionSharingToken(c *C) {
 }
 
 func (s *ServerRequiredSuite) TestPutAskGetInvalidToken(c *C) {
-       kc := runProxy(c, false, false)
+       kc, _ := runProxy(c, false, false, nil)
        defer closeListener()
 
        // Put a test block
@@ -630,16 +744,16 @@ func (s *ServerRequiredSuite) TestPutAskGetInvalidToken(c *C) {
                        _, _, _, err = kc.Get(hash)
                        c.Assert(err, FitsTypeOf, &keepclient.ErrNotFound{})
                        c.Check(err.(*keepclient.ErrNotFound).Temporary(), Equals, false)
-                       c.Check(err, ErrorMatches, ".*HTTP 403 \"Missing or invalid Authorization header\".*")
+                       c.Check(err, ErrorMatches, ".*HTTP 403 \"Missing or invalid Authorization header, or method not allowed\".*")
                }
 
                _, _, err = kc.PutB([]byte("foo"))
-               c.Check(err, ErrorMatches, ".*403.*Missing or invalid Authorization header")
+               c.Check(err, ErrorMatches, ".*403.*Missing or invalid Authorization header, or method not allowed")
        }
 }
 
 func (s *ServerRequiredSuite) TestAskGetKeepProxyConnectionError(c *C) {
-       kc := runProxy(c, false, false)
+       kc, _ := runProxy(c, false, false, nil)
        defer closeListener()
 
        // Point keepproxy at a non-existent keepstore
@@ -665,7 +779,7 @@ func (s *ServerRequiredSuite) TestAskGetKeepProxyConnectionError(c *C) {
 }
 
 func (s *NoKeepServerSuite) TestAskGetNoKeepServerError(c *C) {
-       kc := runProxy(c, false, false)
+       kc, _ := runProxy(c, false, false, nil)
        defer closeListener()
 
        hash := fmt.Sprintf("%x", md5.Sum([]byte("foo")))
@@ -688,10 +802,11 @@ func (s *NoKeepServerSuite) TestAskGetNoKeepServerError(c *C) {
 }
 
 func (s *ServerRequiredSuite) TestPing(c *C) {
-       kc := runProxy(c, false, false)
+       kc, _ := runProxy(c, false, false, nil)
        defer closeListener()
 
-       rtr := MakeRESTRouter(kc, 10*time.Second, arvadostest.ManagementToken)
+       rtr, err := MakeRESTRouter(kc, 10*time.Second, &arvados.Cluster{ManagementToken: arvadostest.ManagementToken}, log.New())
+       c.Assert(err, check.IsNil)
 
        req, err := http.NewRequest("GET",
                "http://"+listener.Addr().String()+"/_health/ping",
index 959f16d8985f0ccaa4aad88449db0b40e6dbe698..997da57e052db81a25306507b23b3f60935b129e 100755 (executable)
@@ -41,6 +41,13 @@ def getuserinfo(arv, uuid):
                                                        arv.config()["Services"]["Workbench1"]["ExternalURL"],
                                                        uuid, prof)
 
+collectionNameCache = {}
+def getCollectionName(arv, uuid):
+    if uuid not in collectionNameCache:
+        u = arv.collections().get(uuid=uuid).execute()
+        collectionNameCache[uuid] = u["name"]
+    return collectionNameCache[uuid]
+
 def getname(u):
     return "\"%s\" (%s)" % (u["name"], u["uuid"])
 
@@ -137,6 +144,19 @@ def main(arguments=None):
             else:
                 users[owner].append("%s Deleted collection %s %s" % (event_at, getname(e["properties"]["old_attributes"]), loguuid))
 
+        elif e["event_type"] == "file_download":
+                users[e["object_uuid"]].append("%s Downloaded file \"%s\" from \"%s\" (%s) (%s)" % (event_at,
+                                                                                       e["properties"].get("collection_file_path") or e["properties"].get("reqPath"),
+                                                                                       getCollectionName(arv, e["properties"].get("collection_uuid")),
+                                                                                       e["properties"].get("collection_uuid"),
+                                                                                       e["properties"].get("portable_data_hash")))
+
+        elif e["event_type"] == "file_upload":
+                users[e["object_uuid"]].append("%s Uploaded file \"%s\" to \"%s\" (%s)" % (event_at,
+                                                                                    e["properties"].get("collection_file_path") or e["properties"].get("reqPath"),
+                                                                                    getCollectionName(arv, e["properties"].get("collection_uuid")),
+                                                                                    e["properties"].get("collection_uuid")))
+
         else:
             users[owner].append("%s %s %s %s" % (e["event_type"], e["object_kind"], e["object_uuid"], loguuid))