16573: Merge branch 'master' into 16573-keep-deduplication-reporting-tool
authorWard Vandewege <ward@curii.com>
Fri, 10 Jul 2020 14:53:46 +0000 (10:53 -0400)
committerWard Vandewege <ward@curii.com>
Fri, 10 Jul 2020 14:53:59 +0000 (10:53 -0400)
Arvados-DCO-1.1-Signed-off-by: Ward Vandewege <ward@curii.com>

44 files changed:
build/run-build-packages-one-target.sh
build/run-build-packages.sh
build/run-library.sh
doc/_config.yml
doc/admin/link-accounts.html.textile.liquid [new file with mode: 0644]
doc/api/methods/users.html.textile.liquid
doc/install/install-api-server.html.textile.liquid
doc/install/install-arv-git-httpd.html.textile.liquid
doc/install/install-keep-web.html.textile.liquid
doc/install/install-keepproxy.html.textile.liquid
doc/install/install-webshell.html.textile.liquid
doc/install/install-workbench-app.html.textile.liquid
doc/install/install-workbench2-app.html.textile.liquid
doc/install/install-ws.html.textile.liquid
lib/controller/federation.go
lib/controller/federation/federation_test.go
lib/controller/handler.go
lib/controller/localdb/db.go [new file with mode: 0644]
lib/controller/localdb/db_test.go [new file with mode: 0644]
lib/controller/localdb/docker_test.go [new file with mode: 0644]
lib/controller/localdb/login.go
lib/controller/localdb/login_ldap_docker_test.go
lib/controller/localdb/login_ldap_docker_test.sh
lib/controller/localdb/login_ldap_test.go
lib/controller/localdb/login_oidc_test.go
lib/controller/router/router.go
lib/controller/router/router_test.go
lib/pam/.gitignore [new file with mode: 0644]
lib/pam/README [new file with mode: 0644]
lib/pam/docker_test.go [new file with mode: 0644]
lib/pam/fpm-info.sh [new file with mode: 0644]
lib/pam/pam-configs-arvados [new file with mode: 0644]
lib/pam/pam_arvados.go [new file with mode: 0644]
lib/pam/pam_c.go [new file with mode: 0644]
lib/pam/testclient.go [new file with mode: 0644]
sdk/R/install_deps.R
sdk/cwl/arvados_cwl/runner.py
sdk/cwl/tests/arvados-tests.yml
sdk/cwl/tests/wf-defaults/default-dir4.cwl
sdk/cwl/tests/wf-defaults/default-dir8.cwl [new file with mode: 0644]
sdk/cwl/tests/wf-defaults/wf4.cwl
sdk/cwl/tests/wf-defaults/wf8.cwl [new file with mode: 0644]
sdk/go/arvados/link.go
sdk/go/arvados/virtual_machine.go [new file with mode: 0644]

index 1a845d200a38c53aeaf3f353e279cf7289a01179..f8816dbe4873c3fad3773d47590393d1e62b5550 100755 (executable)
@@ -208,6 +208,8 @@ if test -z "$packages" ; then
         keepstore
         keep-web
         libarvados-perl
+        libpam-arvados
+        libpam-arvados-go
         python-arvados-fuse
         python-arvados-python-client
         python-arvados-cwl-runner"
index 862b93e6e4667ad561c478e13b0f6f53ee2d012f..5aa0b7e6f8e363642cf3aebfa6bff44d28926d2d 100755 (executable)
@@ -3,8 +3,8 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
-. `dirname "$(readlink -f "$0")"`/run-library.sh
-. `dirname "$(readlink -f "$0")"`/libcloud-pin.sh
+. `dirname "$(readlink -f "$0")"`/run-library.sh || exit 1
+. `dirname "$(readlink -f "$0")"`/libcloud-pin.sh || exit 1
 
 read -rd "\000" helpmessage <<EOF
 $(basename $0): Build Arvados packages
@@ -223,7 +223,7 @@ if [[ -z "$ONLY_BUILD" ]] || [[ "libarvados-perl" = "$ONLY_BUILD" ]] ; then
 
     perl Makefile.PL INSTALL_BASE=install >"$STDOUT_IF_DEBUG" && \
         make install INSTALLDIRS=perl >"$STDOUT_IF_DEBUG" && \
-        fpm_build install/lib/=/usr/share libarvados-perl \
+        fpm_build "$WORKSPACE/sdk/perl" install/lib/=/usr/share libarvados-perl \
         dir "$(version_from_git)" install/man/=/usr/share/man \
         "$WORKSPACE/apache-2.0.txt=/usr/share/doc/libarvados-perl/apache-2.0.txt" && \
         mv --no-clobber libarvados-perl*.$FORMAT "$WORKSPACE/packages/$TARGET/"
@@ -271,7 +271,7 @@ debug_echo -e "\nPython packages\n"
       cd "$SRC_BUILD_DIR"
       PKG_VERSION=$(version_from_git)
       cd $WORKSPACE/packages/$TARGET
-      fpm_build $SRC_BUILD_DIR/=/usr/local/arvados/src arvados-src 'dir' "$PKG_VERSION" "--exclude=usr/local/arvados/src/.git" "--url=https://arvados.org" "--license=GNU Affero General Public License, version 3.0" "--description=The Arvados source code" "--architecture=all"
+      fpm_build "$WORKSPACE" $SRC_BUILD_DIR/=/usr/local/arvados/src arvados-src 'dir' "$PKG_VERSION" "--exclude=usr/local/arvados/src/.git" "--url=https://arvados.org" "--license=GNU Affero General Public License, version 3.0" "--description=The Arvados source code" "--architecture=all"
 
       rm -rf "$SRC_BUILD_DIR"
     fi
@@ -318,6 +318,8 @@ package_go_binary tools/keep-rsync keep-rsync \
     "Copy all data from one set of Keep servers to another"
 package_go_binary tools/keep-exercise keep-exercise \
     "Performance testing tool for Arvados Keep"
+package_go_so lib/pam pam_arvados.so libpam-arvados-go \
+    "Arvados PAM authentication module"
 
 # The Python SDK - Should be built first because it's needed by others
 fpm_build_virtualenv "arvados-python-client" "sdk/python"
index f8e5129daeb0ce63aba2230c04214ca252bba476..3e6c9f85841d55be0e7d9794c4e86a693e5500c3 100755 (executable)
@@ -146,7 +146,7 @@ calculate_go_package_version() {
   __returnvar="$version"
 }
 
-# Usage: package_go_binary services/foo arvados-foo "Compute foo to arbitrary precision"
+# Usage: package_go_binary services/foo arvados-foo "Compute foo to arbitrary precision" [apache-2.0.txt]
 package_go_binary() {
     local src_path="$1"; shift
     local prog="$1"; shift
@@ -185,7 +185,37 @@ package_go_binary() {
     fi
     switches+=("$WORKSPACE/${license_file}=/usr/share/doc/$prog/${license_file}")
 
-    fpm_build "$GOPATH/bin/${basename}=/usr/bin/${prog}" "${prog}" dir "${go_package_version}" "--url=https://arvados.org" "--license=GNU Affero General Public License, version 3.0" "--description=${description}" "${switches[@]}"
+    fpm_build "${WORKSPACE}/${src_path}" "$GOPATH/bin/${basename}=/usr/bin/${prog}" "${prog}" dir "${go_package_version}" "--url=https://arvados.org" "--license=GNU Affero General Public License, version 3.0" "--description=${description}" "${switches[@]}"
+}
+
+# Usage: package_go_so lib/foo arvados_foo.so arvados-foo "Arvados foo library"
+package_go_so() {
+    local src_path="$1"; shift
+    local sofile="$1"; shift
+    local pkg="$1"; shift
+    local description="$1"; shift
+
+    debug_echo "package_go_so $src_path as $pkg"
+
+    calculate_go_package_version go_package_version $src_path
+    cd $WORKSPACE/packages/$TARGET
+    test_package_presence $pkg $go_package_version go || return 1
+    cd $WORKSPACE/$src_path
+    go build -buildmode=c-shared -o ${GOPATH}/bin/${sofile}
+    cd $WORKSPACE/packages/$TARGET
+    local -a fpmargs=(
+        "--url=https://arvados.org"
+        "--license=Apache License, Version 2.0"
+        "--description=${description}"
+        "$WORKSPACE/apache-2.0.txt=/usr/share/doc/$pkg/apache-2.0.txt"
+    )
+    if [[ -e "$WORKSPACE/$src_path/pam-configs-arvados" ]]; then
+        fpmargs+=("$WORKSPACE/$src_path/pam-configs-arvados=/usr/share/pam-configs/arvados-go")
+    fi
+    if [[ -e "$WORKSPACE/$src_path/README" ]]; then
+        fpmargs+=("$WORKSPACE/$src_path/README=/usr/share/doc/$pkg/README")
+    fi
+    fpm_build "${WORKSPACE}/${src_path}" "$GOPATH/bin/${sofile}=/usr/lib/${sofile}" "${pkg}" dir "${go_package_version}" "${fpmargs[@]}"
 }
 
 default_iteration() {
@@ -417,7 +447,7 @@ handle_rails_package() {
     for exclude in ${exclude_list[@]}; do
         switches+=(-x "$exclude_root/$exclude")
     done
-    fpm_build "${pos_args[@]}" "${switches[@]}" \
+    fpm_build "${srcdir}" "${pos_args[@]}" "${switches[@]}" \
               -x "$exclude_root/vendor/cache-*" \
               -x "$exclude_root/vendor/bundle" "$@" "$license_arg"
     rm -rf "$scripts_dir"
@@ -736,6 +766,9 @@ fpm_build_virtualenv () {
 
 # Build packages for everything
 fpm_build () {
+  # Source dir where fpm-info.sh (if any) will be found.
+  SRC_DIR=$1
+  shift
   # The package source.  Depending on the source type, this can be a
   # path, or the name of the package in an upstream repository (e.g.,
   # pip).
@@ -812,17 +845,15 @@ fpm_build () {
   declare -a build_depends=()
   declare -a fpm_depends=()
   declare -a fpm_exclude=()
-  declare -a fpm_dirs=(
-      # source dir part of 'dir' package ("/source=/dest" => "/source"):
-      "${PACKAGE%%=/*}")
-  for pkgdir in "${fpm_dirs[@]}"; do
-      fpminfo="$pkgdir/fpm-info.sh"
-      if [[ -e "$fpminfo" ]]; then
-          debug_echo "Loading fpm overrides from $fpminfo"
-          source "$fpminfo"
-          break
-      fi
-  done
+  if [[ ! -d "$SRC_DIR" ]]; then
+      echo >&2 "BUG: looking in wrong dir for fpm-info.sh: $pkgdir"
+      exit 1
+  fi
+  fpminfo="${SRC_DIR}/fpm-info.sh"
+  if [[ -e "$fpminfo" ]]; then
+      debug_echo "Loading fpm overrides from $fpminfo"
+      source "$fpminfo"
+  fi
   for pkg in "${build_depends[@]}"; do
       if [[ $TARGET =~ debian|ubuntu ]]; then
           pkg_deb=$(ls "$WORKSPACE/packages/$TARGET/$pkg_"*.deb | sort -rg | awk 'NR==1')
index 7d7101f4108ff2cb36af39cdce87cada14a4c979..be52a204c02d4e9548eeaa1139ff8126cff4f400 100644 (file)
@@ -153,8 +153,9 @@ navbar:
       - admin/index.html.textile.liquid
     - Users and Groups:
       - admin/user-management.html.textile.liquid
-      - admin/reassign-ownership.html.textile.liquid
       - admin/user-management-cli.html.textile.liquid
+      - admin/reassign-ownership.html.textile.liquid
+      - admin/link-accounts.html.textile.liquid
       - admin/group-management.html.textile.liquid
       - admin/federation.html.textile.liquid
       - admin/merge-remote-account.html.textile.liquid
diff --git a/doc/admin/link-accounts.html.textile.liquid b/doc/admin/link-accounts.html.textile.liquid
new file mode 100644 (file)
index 0000000..d0ac6a0
--- /dev/null
@@ -0,0 +1,48 @@
+---
+layout: default
+navsection: admin
+title: "Link user accounts"
+...
+{% comment %}
+Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: CC-BY-SA-3.0
+{% endcomment %}
+
+If a user needs to log in to Arvados with a upstream account or provider, they may end up with two Arvados user accounts.  If the user still has the ability to log in with the old account, they can use the "self-serve account linking":{{site.baseurl}}/user/topics/link-accounts.html feature of workbench.  However, if the user does not have the ability to log in with both upstream accounts, the admin can also link the accounts using the command line.
+
+h3. Step 1: Determine user uuids
+
+User uuids can be determined by browsing workbench or using @arv user list@ at the command line.
+
+Account linking works by recording in the database that a log in to the "old" account should redirected and treated as a login to the "new" account.
+
+The "old" account is the Arvados account that will be redirected.
+
+The "new" account is the user that the "old" account is redirected to.  As part of account linking any Arvados records owned by the "old" account is also transferred to the "new" account.
+
+Counter-intuitively, if you do not want the account uuid of the user to change, the "new" account should be the pre-existing account, and the "old" account should be the redundant second account that was more recently created.  This means "old" and "new" are opposite from their expected chronological meaning.  In this case, the use of "old" and "new" reflect the direction of transfer of ownership -- the login was associated with the "old" user account, but will be associated with the "new" user account.
+
+In the example below, @zzzzz-tpzed-3kz0nwtjehhl0u4@ is the "old" account (the pre-existing account we want to keep) and @zzzzz-tpzed-fr97h9t4m5jffxs@ is the "new" account (the redundant account we want to merge into the existing account).
+
+h3. Step 2: Create a project
+
+Create a project owned by the "new" account that will hold any data owned by the "old" account.
+
+<pre>
+$ arv --format=uuid group create --group '{"group_class": "project", "name": "Data from old user", "owner_uuid": "zzzzz-tpzed-fr97h9t4m5jffxs"}'
+zzzzz-j7d0g-mczqiguhil13083
+</pre>
+
+h3. Step 3: Merge "old" user to "new" user
+
+The @user merge@ method redirects login and reassigns data from the "old" account to the "new" account.
+
+<pre>
+$ arv user merge  --redirect-to-new-user \
+  --old-user-uuid=zzzzz-tpzed-3kz0nwtjehhl0u4 \
+  --new-user-uuid=zzzzz-tpzed-fr97h9t4m5jffxs \
+  --new-owner-uuid=zzzzz-j7d0g-mczqiguhil13083 \
+</pre>
+
+Note that authorization credentials (API tokens, ssh keys) are also transferred to the "new" account, so credentials used to access the "old" account work with the "new" account.
index 4c33f2afe820df5e662622b5880a9fd75f3561a6..cde189d6ffa341833cadd7cd08be32fd79146a7c 100644 (file)
@@ -154,3 +154,21 @@ Arguments:
 table(table table-bordered table-condensed).
 |_. Argument |_. Type |_. Description |_. Location |_. Example |
 {background:#ccffcc}.|uuid|string|The UUID of the User in question.|path||
+
+h3. merge
+
+Transfer ownership of data from the "old" user account to the "new" user account.  When @redirect_to_new_user@ is @true@ this also causes logins to the "old" account to be redirected to the "new" account.  The "old" user account that was redirected becomes invisible in user listings.
+
+See "Merge user accounts":{{site.baseurl}}/admin/link-accounts.html , "Reassign user data ownership":{{site.baseurl}}/admin/reassign-ownership.html and "Linking alternate login accounts":{{site.baseurl}}/user/topics/link-accounts.html for examples of how this method is used.
+
+Must supply either @new_user_token@ (the currently authorized user will be the "old" user), or both @new_user_uuid@ and @old_user_uuid@ (the currently authorized user must be an admin).
+
+Arguments:
+
+table(table table-bordered table-condensed).
+|_. Argument |_. Type |_. Description |_. Location |_. Example |
+|new_user_token|string|A valid token for the "new" user|query||
+|new_user_uuid|uuid|The uuid of the "new" account|query||
+|old_user_uuid|uuid|The uuid of the "old" account|query||
+|new_owner_uuid|uuid|The uuid of a project to which objects owned by the "old" user will be reassigned.|query||
+|redirect_to_new_user|boolean|If true, also redirect login and reassign authorization credentials from "old" user to the "new" user|query||
index e64c3826694f257b5efe688059a39bdf72966021..b8442eb0603dfd5279572c3ec1bc28e7b5bc4e47 100644 (file)
@@ -142,10 +142,9 @@ server {
   # This configures the public https port that clients will actually connect to,
   # the request is reverse proxied to the upstream 'controller'
 
-  listen       *:443 ssl;
-  server_name  <span class="userinput">xxxxx.example.com</span>;
+  listen       443 ssl;
+  server_name  <span class="userinput">ClusterID.example.com</span>;
 
-  ssl on;
   ssl_certificate     <span class="userinput">/YOUR/PATH/TO/cert.pem</span>;
   ssl_certificate_key <span class="userinput">/YOUR/PATH/TO/cert.key</span>;
 
index d501f46b7a02bed09d4d3908716ed7cc442af78f..3d70fc4de9497e8bb20b48cec6ecdfa8f62ef2ca 100644 (file)
@@ -224,12 +224,11 @@ Use a text editor to create a new file @/etc/nginx/conf.d/arvados-git.conf@ with
   server                  127.0.0.1:<span class="userinput">9001</span>;
 }
 server {
-  listen                  *:443 ssl;
+  listen                  443 ssl;
   server_name             git.<span class="userinput">ClusterID.example.com</span>;
   proxy_connect_timeout   90s;
   proxy_read_timeout      300s;
 
-  ssl on;
   ssl_certificate         <span class="userinput">/YOUR/PATH/TO/cert.pem</span>;
   ssl_certificate_key     <span class="userinput">/YOUR/PATH/TO/cert.key</span>;
 
index 0dfcac37e001a9f81b9fa9c4cd6643eeba2c09c5..b31827bf70ed6c0a062cef1321cce68c393caaba 100644 (file)
@@ -121,7 +121,7 @@ upstream keep-web {
 }
 
 server {
-  listen                *:443 ssl;
+  listen                443 ssl;
   server_name           <span class="userinput">download.ClusterID.example.com</span>
                         <span class="userinput">collections.ClusterID.example.com</span>
                         <span class="userinput">*.collections.ClusterID.example.com</span>
index 0839c0e521bd942df9ca4d7f78678bbee805c263..ae6bd3989c340cbc64bb67932d9c1c3d8a8121e9 100644 (file)
@@ -58,7 +58,7 @@ Use a text editor to create a new file @/etc/nginx/conf.d/keepproxy.conf@ with t
 }
 
 server {
-  listen                  *:443 ssl;
+  listen                  443 ssl;
   server_name             <span class="userinput">keep.ClusterID.example.com</span>;
 
   proxy_connect_timeout   90s;
@@ -67,7 +67,6 @@ server {
   proxy_http_version      1.1;
   proxy_request_buffering off;
 
-  ssl on;
   ssl_certificate     <span class="userinput">/YOUR/PATH/TO/cert.pem</span>;
   ssl_certificate_key <span class="userinput">/YOUR/PATH/TO/cert.key</span>;
 
index 4040fcf54f833f7ad5ed2bbce01df6cbbb9a1846..ae6a8d2109c686a3d6769e515e75d4376c1e8bee 100644 (file)
@@ -99,7 +99,7 @@ Note that the location line in the nginx config matches your shell node hostname
 
 For additional shell nodes with @shell-in-a-box@, add @location@ and @upstream@ sections as needed.
 
-{% assign arvados_component = 'shellinabox libpam-arvados' %}
+{% assign arvados_component = 'shellinabox libpam-arvados-go' %}
 
 {% include 'install_packages' %}
 
@@ -149,9 +149,8 @@ h2(#config-pam). Configure pam
 Use a text editor to create a new file @/etc/pam.d/shellinabox@ with the following configuration. Options that need attention are marked in <span class="userinput">red</span>.
 
 <notextile><pre>
-# This example is a stock debian "login" file with libpam_arvados
-# replacing pam_unix, and the "noprompt" option in use. It can be
-# installed as /etc/pam.d/shellinabox .
+# This example is a stock debian "login" file with pam_arvados
+# replacing pam_unix. It can be installed as /etc/pam.d/shellinabox .
 
 auth       optional   pam_faildelay.so  delay=3000000
 auth [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] pam_securetty.so
@@ -160,7 +159,7 @@ session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux
 session       required   pam_env.so readenv=1
 session       required   pam_env.so readenv=1 envfile=/etc/default/locale
 
-auth [success=1 default=ignore] pam_python.so /usr/lib/security/libpam_arvados.py <span class="userinput">ClusterID.example.com</span> <span class="userinput">shell.ClusterID.example.com</span> noprompt
+auth [success=1 default=ignore] /usr/lib/pam_arvados.so <span class="userinput">ClusterID.example.com</span> <span class="userinput">shell.ClusterID.example.com</span>
 auth    requisite            pam_deny.so
 auth    required            pam_permit.so
 
index 3d391724dc1e619590c97cb0bb47c25971e6e05d..7ee8db92f18b94381515e45e67d4d2f8ffb8ea67 100644 (file)
@@ -62,10 +62,9 @@ Use a text editor to create a new file @/etc/nginx/conf.d/arvados-workbench.conf
 }
 
 server {
-  listen       *:443 ssl;
+  listen       443 ssl;
   server_name  workbench.<span class="userinput">ClusterID.example.com</span>;
 
-  ssl on;
   ssl_certificate     <span class="userinput">/YOUR/PATH/TO/cert.pem</span>;
   ssl_certificate_key <span class="userinput">/YOUR/PATH/TO/cert.key</span>;
 
index b59799c43fef0ef45915e4deef8200bfe85ff096..f3a320b10251745f64a8d7eece1e36fb73628e6a 100644 (file)
@@ -47,10 +47,9 @@ Use a text editor to create a new file @/etc/nginx/conf.d/arvados-workbench2.con
 }
 
 server {
-  listen       *:443 ssl;
+  listen       443 ssl;
   server_name  workbench2.<span class="userinput">ClusterID.example.com</span>;
 
-  ssl on;
   ssl_certificate     <span class="userinput">/YOUR/PATH/TO/cert.pem</span>;
   ssl_certificate_key <span class="userinput">/YOUR/PATH/TO/cert.key</span>;
 
index 11862a6ae6767c73ab935e8e93c4134991026789..2b982504f2e705e4334984d35f48aa4ebf1fa05f 100644 (file)
@@ -43,7 +43,7 @@ upstream arvados-ws {
 }
 
 server {
-  listen                *:443 ssl;
+  listen                443 ssl;
   server_name           ws.<span class="userinput">ClusterID.example.com</span>;
 
   proxy_connect_timeout 90s;
index ac239fb9b23f5c4c106034e2a910fb441b9c2218..aceaba8087ad2031413516c2671f75174c457fae 100644 (file)
@@ -152,7 +152,7 @@ type CurrentUser struct {
 // non-nil, true, nil -- if the token is valid
 func (h *Handler) validateAPItoken(req *http.Request, token string) (*CurrentUser, bool, error) {
        user := CurrentUser{Authorization: arvados.APIClientAuthorization{APIToken: token}}
-       db, err := h.db(req)
+       db, err := h.db(req.Context())
        if err != nil {
                ctxlog.FromContext(req.Context()).WithError(err).Debugf("validateAPItoken(%s): database error", token)
                return nil, false, err
@@ -189,7 +189,7 @@ func (h *Handler) validateAPItoken(req *http.Request, token string) (*CurrentUse
 }
 
 func (h *Handler) createAPItoken(req *http.Request, userUUID string, scopes []string) (*arvados.APIClientAuthorization, error) {
-       db, err := h.db(req)
+       db, err := h.db(req.Context())
        if err != nil {
                return nil, err
        }
index f57d827848cbf772e4e7b9c3c2b8ac1e860f18e2..256afc8e6b9482d53eaa520927f62761a1f71b03 100644 (file)
@@ -64,7 +64,7 @@ func (s *FederationSuite) addDirectRemote(c *check.C, id string, backend backend
 
 func (s *FederationSuite) addHTTPRemote(c *check.C, id string, backend backend) {
        srv := httpserver.Server{Addr: ":"}
-       srv.Handler = router.New(backend)
+       srv.Handler = router.New(backend, nil)
        c.Check(srv.Start(), check.IsNil)
        s.cluster.RemoteClusters[id] = arvados.RemoteCluster{
                Scheme: "http",
index 01f2161632bf8e6562f51b4266e43602b90218c6..cc06246420559479203e24843164cee281e07633 100644 (file)
@@ -16,9 +16,11 @@ import (
        "time"
 
        "git.arvados.org/arvados.git/lib/controller/federation"
+       "git.arvados.org/arvados.git/lib/controller/localdb"
        "git.arvados.org/arvados.git/lib/controller/railsproxy"
        "git.arvados.org/arvados.git/lib/controller/router"
        "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
        "git.arvados.org/arvados.git/sdk/go/health"
        "git.arvados.org/arvados.git/sdk/go/httpserver"
        _ "github.com/lib/pq"
@@ -63,7 +65,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 
 func (h *Handler) CheckHealth() error {
        h.setupOnce.Do(h.setup)
-       _, _, err := railsproxy.FindRailsAPI(h.Cluster)
+       _, err := h.db(context.TODO())
+       if err != nil {
+               return err
+       }
+       _, _, err = railsproxy.FindRailsAPI(h.Cluster)
        return err
 }
 
@@ -78,10 +84,10 @@ func (h *Handler) setup() {
        mux.Handle("/_health/", &health.Handler{
                Token:  h.Cluster.ManagementToken,
                Prefix: "/_health/",
-               Routes: health.Routes{"ping": func() error { _, err := h.db(&http.Request{}); return err }},
+               Routes: health.Routes{"ping": func() error { _, err := h.db(context.TODO()); return err }},
        })
 
-       rtr := router.New(federation.New(h.Cluster))
+       rtr := router.New(federation.New(h.Cluster), localdb.WrapCallsInTransactions(h.db))
        mux.Handle("/arvados/v1/config", rtr)
        mux.Handle("/"+arvados.EndpointUserAuthenticate.Path, rtr)
 
@@ -115,7 +121,7 @@ func (h *Handler) setup() {
 
 var errDBConnection = errors.New("database connection error")
 
-func (h *Handler) db(req *http.Request) (*sql.DB, error) {
+func (h *Handler) db(ctx context.Context) (*sql.DB, error) {
        h.pgdbMtx.Lock()
        defer h.pgdbMtx.Unlock()
        if h.pgdb != nil {
@@ -124,14 +130,14 @@ func (h *Handler) db(req *http.Request) (*sql.DB, error) {
 
        db, err := sql.Open("postgres", h.Cluster.PostgreSQL.Connection.String())
        if err != nil {
-               httpserver.Logger(req).WithError(err).Error("postgresql connect failed")
+               ctxlog.FromContext(ctx).WithError(err).Error("postgresql connect failed")
                return nil, errDBConnection
        }
        if p := h.Cluster.PostgreSQL.ConnectionPool; p > 0 {
                db.SetMaxOpenConns(p)
        }
        if err := db.Ping(); err != nil {
-               httpserver.Logger(req).WithError(err).Error("postgresql connect succeeded but ping failed")
+               ctxlog.FromContext(ctx).WithError(err).Error("postgresql connect scuceeded but ping failed")
                return nil, errDBConnection
        }
        h.pgdb = db
diff --git a/lib/controller/localdb/db.go b/lib/controller/localdb/db.go
new file mode 100644 (file)
index 0000000..4f64e63
--- /dev/null
@@ -0,0 +1,116 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "context"
+       "database/sql"
+       "errors"
+       "sync"
+
+       "git.arvados.org/arvados.git/lib/controller/router"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+)
+
+// WrapCallsInTransactions returns a call wrapper (suitable for
+// assigning to router.router.WrapCalls) that starts a new transaction
+// for each API call, and commits only if the call succeeds.
+//
+// The wrapper calls getdb() to get a database handle before each API
+// call.
+func WrapCallsInTransactions(getdb func(context.Context) (*sql.DB, error)) func(router.RoutableFunc) router.RoutableFunc {
+       return func(origFunc router.RoutableFunc) router.RoutableFunc {
+               return func(ctx context.Context, opts interface{}) (_ interface{}, err error) {
+                       ctx, finishtx := starttx(ctx, getdb)
+                       defer finishtx(&err)
+                       return origFunc(ctx, opts)
+               }
+       }
+}
+
+// ContextWithTransaction returns a child context in which the given
+// transaction will be used by any localdb API call that needs one.
+// The caller is responsible for calling Commit or Rollback on tx.
+func ContextWithTransaction(ctx context.Context, tx *sql.Tx) context.Context {
+       txn := &transaction{tx: tx}
+       txn.setup.Do(func() {})
+       return context.WithValue(ctx, contextKeyTransaction, txn)
+}
+
+type contextKeyT string
+
+var contextKeyTransaction = contextKeyT("transaction")
+
+type transaction struct {
+       tx    *sql.Tx
+       err   error
+       getdb func(context.Context) (*sql.DB, error)
+       setup sync.Once
+}
+
+type transactionFinishFunc func(*error)
+
+// starttx returns a new child context that can be used with
+// currenttx(). It does not open a database transaction until the
+// first call to currenttx().
+//
+// The caller must eventually call the returned finishtx() func to
+// commit or rollback the transaction, if any.
+//
+//     func example(ctx context.Context) (err error) {
+//             ctx, finishtx := starttx(ctx, dber)
+//             defer finishtx(&err)
+//             // ...
+//             tx, err := currenttx(ctx)
+//             if err != nil {
+//                     return fmt.Errorf("example: %s", err)
+//             }
+//             return tx.ExecContext(...)
+//     }
+//
+// If *err is nil, finishtx() commits the transaction and assigns any
+// resulting error to *err.
+//
+// If *err is non-nil, finishtx() rolls back the transaction, and
+// does not modify *err.
+func starttx(ctx context.Context, getdb func(context.Context) (*sql.DB, error)) (context.Context, transactionFinishFunc) {
+       txn := &transaction{getdb: getdb}
+       return context.WithValue(ctx, contextKeyTransaction, txn), func(err *error) {
+               txn.setup.Do(func() {
+                       // Using (*sync.Once)Do() prevents a future
+                       // call to currenttx() from opening a
+                       // transaction which would never get committed
+                       // or rolled back. If currenttx() hasn't been
+                       // called before now, future calls will return
+                       // this error.
+                       txn.err = errors.New("refusing to start a transaction after wrapped function already returned")
+               })
+               if txn.tx == nil {
+                       // we never [successfully] started a transaction
+                       return
+               }
+               if *err != nil {
+                       ctxlog.FromContext(ctx).Debug("rollback")
+                       txn.tx.Rollback()
+                       return
+               }
+               *err = txn.tx.Commit()
+       }
+}
+
+func currenttx(ctx context.Context) (*sql.Tx, error) {
+       txn, ok := ctx.Value(contextKeyTransaction).(*transaction)
+       if !ok {
+               return nil, errors.New("bug: there is no transaction in this context")
+       }
+       txn.setup.Do(func() {
+               if db, err := txn.getdb(ctx); err != nil {
+                       txn.err = err
+               } else {
+                       txn.tx, txn.err = db.Begin()
+               }
+       })
+       return txn.tx, txn.err
+}
diff --git a/lib/controller/localdb/db_test.go b/lib/controller/localdb/db_test.go
new file mode 100644 (file)
index 0000000..5bab86c
--- /dev/null
@@ -0,0 +1,98 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "context"
+       "database/sql"
+       "sync"
+       "sync/atomic"
+
+       "git.arvados.org/arvados.git/lib/config"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       _ "github.com/lib/pq"
+       check "gopkg.in/check.v1"
+)
+
+// testdb returns a DB connection for the given cluster config.
+func testdb(c *check.C, cluster *arvados.Cluster) *sql.DB {
+       db, err := sql.Open("postgres", cluster.PostgreSQL.Connection.String())
+       c.Assert(err, check.IsNil)
+       return db
+}
+
+// testctx returns a context suitable for running a test case in a new
+// transaction, and a rollback func which the caller should call after
+// the test.
+func testctx(c *check.C, db *sql.DB) (ctx context.Context, rollback func()) {
+       tx, err := db.Begin()
+       c.Assert(err, check.IsNil)
+       return ContextWithTransaction(context.Background(), tx), func() {
+               c.Check(tx.Rollback(), check.IsNil)
+       }
+}
+
+var _ = check.Suite(&DatabaseSuite{})
+
+type DatabaseSuite struct{}
+
+func (*DatabaseSuite) TestTransactionContext(c *check.C) {
+       cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
+       c.Assert(err, check.IsNil)
+       cluster, err := cfg.GetCluster("")
+       c.Assert(err, check.IsNil)
+
+       var getterCalled int64
+       getter := func(context.Context) (*sql.DB, error) {
+               atomic.AddInt64(&getterCalled, 1)
+               return testdb(c, cluster), nil
+       }
+       wrapper := WrapCallsInTransactions(getter)
+       wrappedFunc := wrapper(func(ctx context.Context, opts interface{}) (interface{}, error) {
+               txes := make([]*sql.Tx, 20)
+               var wg sync.WaitGroup
+               for i := range txes {
+                       i := i
+                       wg.Add(1)
+                       go func() {
+                               // Concurrent calls to currenttx(),
+                               // with different children of the same
+                               // parent context, will all return the
+                               // same transaction.
+                               defer wg.Done()
+                               ctx, cancel := context.WithCancel(ctx)
+                               defer cancel()
+                               tx, err := currenttx(ctx)
+                               c.Check(err, check.IsNil)
+                               txes[i] = tx
+                       }()
+               }
+               wg.Wait()
+               for i := range txes[1:] {
+                       c.Check(txes[i], check.Equals, txes[i+1])
+               }
+               return true, nil
+       })
+
+       ok, err := wrappedFunc(context.Background(), "blah")
+       c.Check(ok, check.Equals, true)
+       c.Check(err, check.IsNil)
+       c.Check(getterCalled, check.Equals, int64(1))
+
+       // When a wrapped func returns without calling currenttx(),
+       // calling currenttx() later shouldn't start a new
+       // transaction.
+       var savedctx context.Context
+       ok, err = wrapper(func(ctx context.Context, opts interface{}) (interface{}, error) {
+               savedctx = ctx
+               return true, nil
+       })(context.Background(), "blah")
+       c.Check(ok, check.Equals, true)
+       c.Check(err, check.IsNil)
+       tx, err := currenttx(savedctx)
+       c.Check(tx, check.IsNil)
+       c.Check(err, check.NotNil)
+}
diff --git a/lib/controller/localdb/docker_test.go b/lib/controller/localdb/docker_test.go
new file mode 100644 (file)
index 0000000..90c98b7
--- /dev/null
@@ -0,0 +1,68 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "io"
+       "net"
+       "strings"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       check "gopkg.in/check.v1"
+)
+
+type pgproxy struct {
+       net.Listener
+}
+
+// newPgProxy sets up a TCP proxy, listening on all interfaces, that
+// forwards all connections to the cluster's PostgreSQL server. This
+// allows the caller to run a docker container that can connect to a
+// postgresql instance that listens on the test host's loopback
+// interface.
+//
+// Caller is responsible for calling Close() on the returned pgproxy.
+func newPgProxy(c *check.C, cluster *arvados.Cluster) *pgproxy {
+       host := cluster.PostgreSQL.Connection["host"]
+       if host == "" {
+               host = "localhost"
+       }
+       port := cluster.PostgreSQL.Connection["port"]
+       if port == "" {
+               port = "5432"
+       }
+       target := net.JoinHostPort(host, port)
+
+       ln, err := net.Listen("tcp", ":")
+       c.Assert(err, check.IsNil)
+       go func() {
+               for {
+                       downstream, err := ln.Accept()
+                       if err != nil && strings.Contains(err.Error(), "use of closed network connection") {
+                               return
+                       }
+                       c.Assert(err, check.IsNil)
+                       go func() {
+                               c.Logf("pgproxy accepted connection from %s", downstream.RemoteAddr().String())
+                               defer downstream.Close()
+                               upstream, err := net.Dial("tcp", target)
+                               if err != nil {
+                                       c.Logf("net.Dial(%q): %s", target, err)
+                                       return
+                               }
+                               defer upstream.Close()
+                               go io.Copy(downstream, upstream)
+                               io.Copy(upstream, downstream)
+                       }()
+               }
+       }()
+       c.Logf("pgproxy listening at %s", ln.Addr().String())
+       return &pgproxy{Listener: ln}
+}
+
+func (proxy *pgproxy) Port() string {
+       _, port, _ := net.SplitHostPort(proxy.Addr().String())
+       return port
+}
index 905cfed15c500d95689857e36c1c3323165c4d3d..1cd349a10eaa94d987899ac1315f811ffbf186e1 100644 (file)
@@ -6,9 +6,13 @@ package localdb
 
 import (
        "context"
+       "database/sql"
+       "encoding/json"
        "errors"
+       "fmt"
        "net/http"
        "net/url"
+       "strings"
 
        "git.arvados.org/arvados.git/lib/controller/rpc"
        "git.arvados.org/arvados.git/sdk/go/arvados"
@@ -96,9 +100,9 @@ func noopLogout(cluster *arvados.Cluster, opts arvados.LogoutOptions) (arvados.L
        return arvados.LogoutResponse{RedirectLocation: target}, nil
 }
 
-func createAPIClientAuthorization(ctx context.Context, conn *rpc.Conn, rootToken string, authinfo rpc.UserSessionAuthInfo) (arvados.APIClientAuthorization, error) {
+func createAPIClientAuthorization(ctx context.Context, conn *rpc.Conn, rootToken string, authinfo rpc.UserSessionAuthInfo) (resp arvados.APIClientAuthorization, err error) {
        ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{rootToken}})
-       resp, err := conn.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
+       newsession, err := conn.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
                // Send a fake ReturnTo value instead of the caller's
                // opts.ReturnTo. We won't follow the resulting
                // redirect target anyway.
@@ -106,12 +110,36 @@ func createAPIClientAuthorization(ctx context.Context, conn *rpc.Conn, rootToken
                AuthInfo: authinfo,
        })
        if err != nil {
-               return arvados.APIClientAuthorization{}, err
+               return
        }
-       target, err := url.Parse(resp.RedirectLocation)
+       target, err := url.Parse(newsession.RedirectLocation)
        if err != nil {
-               return arvados.APIClientAuthorization{}, err
+               return
        }
        token := target.Query().Get("api_token")
-       return conn.APIClientAuthorizationCurrent(auth.NewContext(ctx, auth.NewCredentials(token)), arvados.GetOptions{})
+       tx, err := currenttx(ctx)
+       if err != nil {
+               return
+       }
+       tokensecret := token
+       if strings.Contains(token, "/") {
+               tokenparts := strings.Split(token, "/")
+               if len(tokenparts) >= 3 {
+                       tokensecret = tokenparts[2]
+               }
+       }
+       var exp sql.NullString
+       var scopes []byte
+       err = tx.QueryRowContext(ctx, "select uuid, api_token, expires_at, scopes from api_client_authorizations where api_token=$1", tokensecret).Scan(&resp.UUID, &resp.APIToken, &exp, &scopes)
+       if err != nil {
+               return
+       }
+       resp.ExpiresAt = exp.String
+       if len(scopes) > 0 {
+               err = json.Unmarshal(scopes, &resp.Scopes)
+               if err != nil {
+                       return resp, fmt.Errorf("unmarshal scopes: %s", err)
+               }
+       }
+       return
 }
index 79b5f16158ab0c39cba73822cb534bd9303ce24c..3cbf14fe0b0c11694bd95831179f3f0b7f6eb586 100644 (file)
@@ -24,10 +24,13 @@ func (s *LDAPSuite) TestLoginLDAPViaPAM(c *check.C) {
        if !haveDocker() {
                c.Skip("skipping docker test because docker is not available")
        }
+       pgproxy := newPgProxy(c, s.cluster)
+       defer pgproxy.Close()
+
        cmd := exec.Command("bash", "login_ldap_docker_test.sh")
        cmd.Stdout = os.Stderr
        cmd.Stderr = os.Stderr
-       cmd.Env = append(os.Environ(), "config_method=pam")
+       cmd.Env = append(os.Environ(), "config_method=pam", "pgport="+pgproxy.Port())
        err := cmd.Run()
        c.Check(err, check.IsNil)
 }
@@ -39,10 +42,13 @@ func (s *LDAPSuite) TestLoginLDAPBuiltin(c *check.C) {
        if !haveDocker() {
                c.Skip("skipping docker test because docker is not available")
        }
+       pgproxy := newPgProxy(c, s.cluster)
+       defer pgproxy.Close()
+
        cmd := exec.Command("bash", "login_ldap_docker_test.sh")
        cmd.Stdout = os.Stderr
        cmd.Stderr = os.Stderr
-       cmd.Env = append(os.Environ(), "config_method=ldap")
+       cmd.Env = append(os.Environ(), "config_method=ldap", "pgport="+pgproxy.Port())
        err := cmd.Run()
        c.Check(err, check.IsNil)
 }
index 4e0679f620bf6b4ec67d8fc118b66db5ef3332ac..0225f204611d051ce5c17a6f5eb594f5845aaa18 100755 (executable)
@@ -1,5 +1,9 @@
 #!/bin/bash
 
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
 # This script demonstrates using LDAP for Arvados user authentication.
 #
 # It configures arvados controller in a docker container, optionally
@@ -74,6 +78,7 @@ Clusters:
       Connection:
         client_encoding: utf8
         host: ${hostname}
+        port: "${pgport}"
         dbname: arvados_test
         user: arvados
         password: insecure_arvados_test
index 9a8f83f857092951f76d4f97d94f5fdef66626a1..64ae58bce2681f792020b1855c2465d5ad226ae1 100644 (file)
@@ -6,6 +6,7 @@ package localdb
 
 import (
        "context"
+       "database/sql"
        "encoding/json"
        "net"
        "net/http"
@@ -26,6 +27,11 @@ type LDAPSuite struct {
        cluster *arvados.Cluster
        ctrl    *ldapLoginController
        ldap    *godap.LDAPServer // fake ldap server that accepts auth goodusername/goodpassword
+       db      *sql.DB
+
+       // transaction context
+       ctx      context.Context
+       rollback func()
 }
 
 func (s *LDAPSuite) TearDownSuite(c *check.C) {
@@ -85,10 +91,21 @@ func (s *LDAPSuite) SetUpSuite(c *check.C) {
                Cluster:    s.cluster,
                RailsProxy: railsproxy.NewConn(s.cluster),
        }
+       s.db = testdb(c, s.cluster)
+}
+
+func (s *LDAPSuite) SetUpTest(c *check.C) {
+       s.ctx, s.rollback = testctx(c, s.db)
+}
+
+func (s *LDAPSuite) TearDownTest(c *check.C) {
+       s.rollback()
 }
 
 func (s *LDAPSuite) TestLoginSuccess(c *check.C) {
-       resp, err := s.ctrl.UserAuthenticate(context.Background(), arvados.UserAuthenticateOptions{
+       conn := NewConn(s.cluster)
+       conn.loginController = s.ctrl
+       resp, err := conn.UserAuthenticate(s.ctx, arvados.UserAuthenticateOptions{
                Username: "goodusername",
                Password: "goodpassword",
        })
@@ -97,7 +114,7 @@ func (s *LDAPSuite) TestLoginSuccess(c *check.C) {
        c.Check(resp.UUID, check.Matches, `zzzzz-gj3su-.*`)
        c.Check(resp.Scopes, check.DeepEquals, []string{"all"})
 
-       ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{"v2/" + resp.UUID + "/" + resp.APIToken}})
+       ctx := auth.NewContext(s.ctx, &auth.Credentials{Tokens: []string{"v2/" + resp.UUID + "/" + resp.APIToken}})
        user, err := railsproxy.NewConn(s.cluster).UserGetCurrent(ctx, arvados.GetOptions{})
        c.Check(err, check.IsNil)
        c.Check(user.Email, check.Equals, "goodusername@example.com")
@@ -107,7 +124,7 @@ func (s *LDAPSuite) TestLoginSuccess(c *check.C) {
 func (s *LDAPSuite) TestLoginFailure(c *check.C) {
        // search returns no results
        s.cluster.Login.LDAP.SearchBase = "dc=example,dc=invalid"
-       resp, err := s.ctrl.UserAuthenticate(context.Background(), arvados.UserAuthenticateOptions{
+       resp, err := s.ctrl.UserAuthenticate(s.ctx, arvados.UserAuthenticateOptions{
                Username: "goodusername",
                Password: "goodpassword",
        })
@@ -120,7 +137,7 @@ func (s *LDAPSuite) TestLoginFailure(c *check.C) {
 
        // search returns result, but auth fails
        s.cluster.Login.LDAP.SearchBase = "dc=example,dc=com"
-       resp, err = s.ctrl.UserAuthenticate(context.Background(), arvados.UserAuthenticateOptions{
+       resp, err = s.ctrl.UserAuthenticate(s.ctx, arvados.UserAuthenticateOptions{
                Username: "badusername",
                Password: "badpassword",
        })
index 1345e86900dd1056da5a9259bf0d5caf179253e5..2ccb1fce2a1e9dc42592f0543b2b0fc03f7d6fea 100644 (file)
@@ -39,7 +39,6 @@ var _ = check.Suite(&OIDCLoginSuite{})
 
 type OIDCLoginSuite struct {
        cluster               *arvados.Cluster
-       ctx                   context.Context
        localdb               *Conn
        railsSpy              *arvadostest.Proxy
        fakeIssuer            *httptest.Server
index c347e2f795517f74c9f67ec0311ba41d3250dafb..29c81ac5cae9ac63431e691852230a00c2335afe 100644 (file)
@@ -19,144 +19,154 @@ import (
 )
 
 type router struct {
-       mux *mux.Router
-       fed arvados.API
+       mux       *mux.Router
+       backend   arvados.API
+       wrapCalls func(RoutableFunc) RoutableFunc
 }
 
-func New(fed arvados.API) *router {
+// New returns a new router (which implements the http.Handler
+// interface) that serves requests by calling Arvados API methods on
+// the given backend.
+//
+// If wrapCalls is not nil, it is called once for each API method, and
+// the returned method is used in its place. This can be used to
+// install hooks before and after each API call and alter responses;
+// see localdb.WrapCallsInTransaction for an example.
+func New(backend arvados.API, wrapCalls func(RoutableFunc) RoutableFunc) *router {
        rtr := &router{
-               mux: mux.NewRouter(),
-               fed: fed,
+               mux:       mux.NewRouter(),
+               backend:   backend,
+               wrapCalls: wrapCalls,
        }
        rtr.addRoutes()
        return rtr
 }
 
-type routableFunc func(ctx context.Context, opts interface{}) (interface{}, error)
+type RoutableFunc func(ctx context.Context, opts interface{}) (interface{}, error)
 
 func (rtr *router) addRoutes() {
        for _, route := range []struct {
                endpoint    arvados.APIEndpoint
                defaultOpts func() interface{}
-               exec        routableFunc
+               exec        RoutableFunc
        }{
                {
                        arvados.EndpointConfigGet,
                        func() interface{} { return &struct{}{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.ConfigGet(ctx)
+                               return rtr.backend.ConfigGet(ctx)
                        },
                },
                {
                        arvados.EndpointLogin,
                        func() interface{} { return &arvados.LoginOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.Login(ctx, *opts.(*arvados.LoginOptions))
+                               return rtr.backend.Login(ctx, *opts.(*arvados.LoginOptions))
                        },
                },
                {
                        arvados.EndpointLogout,
                        func() interface{} { return &arvados.LogoutOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.Logout(ctx, *opts.(*arvados.LogoutOptions))
+                               return rtr.backend.Logout(ctx, *opts.(*arvados.LogoutOptions))
                        },
                },
                {
                        arvados.EndpointCollectionCreate,
                        func() interface{} { return &arvados.CreateOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.CollectionCreate(ctx, *opts.(*arvados.CreateOptions))
+                               return rtr.backend.CollectionCreate(ctx, *opts.(*arvados.CreateOptions))
                        },
                },
                {
                        arvados.EndpointCollectionUpdate,
                        func() interface{} { return &arvados.UpdateOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.CollectionUpdate(ctx, *opts.(*arvados.UpdateOptions))
+                               return rtr.backend.CollectionUpdate(ctx, *opts.(*arvados.UpdateOptions))
                        },
                },
                {
                        arvados.EndpointCollectionGet,
                        func() interface{} { return &arvados.GetOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.CollectionGet(ctx, *opts.(*arvados.GetOptions))
+                               return rtr.backend.CollectionGet(ctx, *opts.(*arvados.GetOptions))
                        },
                },
                {
                        arvados.EndpointCollectionList,
                        func() interface{} { return &arvados.ListOptions{Limit: -1} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.CollectionList(ctx, *opts.(*arvados.ListOptions))
+                               return rtr.backend.CollectionList(ctx, *opts.(*arvados.ListOptions))
                        },
                },
                {
                        arvados.EndpointCollectionProvenance,
                        func() interface{} { return &arvados.GetOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.CollectionProvenance(ctx, *opts.(*arvados.GetOptions))
+                               return rtr.backend.CollectionProvenance(ctx, *opts.(*arvados.GetOptions))
                        },
                },
                {
                        arvados.EndpointCollectionUsedBy,
                        func() interface{} { return &arvados.GetOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.CollectionUsedBy(ctx, *opts.(*arvados.GetOptions))
+                               return rtr.backend.CollectionUsedBy(ctx, *opts.(*arvados.GetOptions))
                        },
                },
                {
                        arvados.EndpointCollectionDelete,
                        func() interface{} { return &arvados.DeleteOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.CollectionDelete(ctx, *opts.(*arvados.DeleteOptions))
+                               return rtr.backend.CollectionDelete(ctx, *opts.(*arvados.DeleteOptions))
                        },
                },
                {
                        arvados.EndpointCollectionTrash,
                        func() interface{} { return &arvados.DeleteOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.CollectionTrash(ctx, *opts.(*arvados.DeleteOptions))
+                               return rtr.backend.CollectionTrash(ctx, *opts.(*arvados.DeleteOptions))
                        },
                },
                {
                        arvados.EndpointCollectionUntrash,
                        func() interface{} { return &arvados.UntrashOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.CollectionUntrash(ctx, *opts.(*arvados.UntrashOptions))
+                               return rtr.backend.CollectionUntrash(ctx, *opts.(*arvados.UntrashOptions))
                        },
                },
                {
                        arvados.EndpointContainerCreate,
                        func() interface{} { return &arvados.CreateOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.ContainerCreate(ctx, *opts.(*arvados.CreateOptions))
+                               return rtr.backend.ContainerCreate(ctx, *opts.(*arvados.CreateOptions))
                        },
                },
                {
                        arvados.EndpointContainerUpdate,
                        func() interface{} { return &arvados.UpdateOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.ContainerUpdate(ctx, *opts.(*arvados.UpdateOptions))
+                               return rtr.backend.ContainerUpdate(ctx, *opts.(*arvados.UpdateOptions))
                        },
                },
                {
                        arvados.EndpointContainerGet,
                        func() interface{} { return &arvados.GetOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.ContainerGet(ctx, *opts.(*arvados.GetOptions))
+                               return rtr.backend.ContainerGet(ctx, *opts.(*arvados.GetOptions))
                        },
                },
                {
                        arvados.EndpointContainerList,
                        func() interface{} { return &arvados.ListOptions{Limit: -1} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.ContainerList(ctx, *opts.(*arvados.ListOptions))
+                               return rtr.backend.ContainerList(ctx, *opts.(*arvados.ListOptions))
                        },
                },
                {
                        arvados.EndpointContainerDelete,
                        func() interface{} { return &arvados.DeleteOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.ContainerDelete(ctx, *opts.(*arvados.DeleteOptions))
+                               return rtr.backend.ContainerDelete(ctx, *opts.(*arvados.DeleteOptions))
                        },
                },
                {
@@ -165,7 +175,7 @@ func (rtr *router) addRoutes() {
                                return &arvados.GetOptions{Select: []string{"uuid", "state", "priority", "auth_uuid", "locked_by_uuid"}}
                        },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.ContainerLock(ctx, *opts.(*arvados.GetOptions))
+                               return rtr.backend.ContainerLock(ctx, *opts.(*arvados.GetOptions))
                        },
                },
                {
@@ -174,144 +184,148 @@ func (rtr *router) addRoutes() {
                                return &arvados.GetOptions{Select: []string{"uuid", "state", "priority", "auth_uuid", "locked_by_uuid"}}
                        },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.ContainerUnlock(ctx, *opts.(*arvados.GetOptions))
+                               return rtr.backend.ContainerUnlock(ctx, *opts.(*arvados.GetOptions))
                        },
                },
                {
                        arvados.EndpointSpecimenCreate,
                        func() interface{} { return &arvados.CreateOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.SpecimenCreate(ctx, *opts.(*arvados.CreateOptions))
+                               return rtr.backend.SpecimenCreate(ctx, *opts.(*arvados.CreateOptions))
                        },
                },
                {
                        arvados.EndpointSpecimenUpdate,
                        func() interface{} { return &arvados.UpdateOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.SpecimenUpdate(ctx, *opts.(*arvados.UpdateOptions))
+                               return rtr.backend.SpecimenUpdate(ctx, *opts.(*arvados.UpdateOptions))
                        },
                },
                {
                        arvados.EndpointSpecimenGet,
                        func() interface{} { return &arvados.GetOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.SpecimenGet(ctx, *opts.(*arvados.GetOptions))
+                               return rtr.backend.SpecimenGet(ctx, *opts.(*arvados.GetOptions))
                        },
                },
                {
                        arvados.EndpointSpecimenList,
                        func() interface{} { return &arvados.ListOptions{Limit: -1} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.SpecimenList(ctx, *opts.(*arvados.ListOptions))
+                               return rtr.backend.SpecimenList(ctx, *opts.(*arvados.ListOptions))
                        },
                },
                {
                        arvados.EndpointSpecimenDelete,
                        func() interface{} { return &arvados.DeleteOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.SpecimenDelete(ctx, *opts.(*arvados.DeleteOptions))
+                               return rtr.backend.SpecimenDelete(ctx, *opts.(*arvados.DeleteOptions))
                        },
                },
                {
                        arvados.EndpointUserCreate,
                        func() interface{} { return &arvados.CreateOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.UserCreate(ctx, *opts.(*arvados.CreateOptions))
+                               return rtr.backend.UserCreate(ctx, *opts.(*arvados.CreateOptions))
                        },
                },
                {
                        arvados.EndpointUserMerge,
                        func() interface{} { return &arvados.UserMergeOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.UserMerge(ctx, *opts.(*arvados.UserMergeOptions))
+                               return rtr.backend.UserMerge(ctx, *opts.(*arvados.UserMergeOptions))
                        },
                },
                {
                        arvados.EndpointUserActivate,
                        func() interface{} { return &arvados.UserActivateOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.UserActivate(ctx, *opts.(*arvados.UserActivateOptions))
+                               return rtr.backend.UserActivate(ctx, *opts.(*arvados.UserActivateOptions))
                        },
                },
                {
                        arvados.EndpointUserSetup,
                        func() interface{} { return &arvados.UserSetupOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.UserSetup(ctx, *opts.(*arvados.UserSetupOptions))
+                               return rtr.backend.UserSetup(ctx, *opts.(*arvados.UserSetupOptions))
                        },
                },
                {
                        arvados.EndpointUserUnsetup,
                        func() interface{} { return &arvados.GetOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.UserUnsetup(ctx, *opts.(*arvados.GetOptions))
+                               return rtr.backend.UserUnsetup(ctx, *opts.(*arvados.GetOptions))
                        },
                },
                {
                        arvados.EndpointUserGetCurrent,
                        func() interface{} { return &arvados.GetOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.UserGetCurrent(ctx, *opts.(*arvados.GetOptions))
+                               return rtr.backend.UserGetCurrent(ctx, *opts.(*arvados.GetOptions))
                        },
                },
                {
                        arvados.EndpointUserGetSystem,
                        func() interface{} { return &arvados.GetOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.UserGetSystem(ctx, *opts.(*arvados.GetOptions))
+                               return rtr.backend.UserGetSystem(ctx, *opts.(*arvados.GetOptions))
                        },
                },
                {
                        arvados.EndpointUserGet,
                        func() interface{} { return &arvados.GetOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.UserGet(ctx, *opts.(*arvados.GetOptions))
+                               return rtr.backend.UserGet(ctx, *opts.(*arvados.GetOptions))
                        },
                },
                {
                        arvados.EndpointUserUpdateUUID,
                        func() interface{} { return &arvados.UpdateUUIDOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.UserUpdateUUID(ctx, *opts.(*arvados.UpdateUUIDOptions))
+                               return rtr.backend.UserUpdateUUID(ctx, *opts.(*arvados.UpdateUUIDOptions))
                        },
                },
                {
                        arvados.EndpointUserUpdate,
                        func() interface{} { return &arvados.UpdateOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.UserUpdate(ctx, *opts.(*arvados.UpdateOptions))
+                               return rtr.backend.UserUpdate(ctx, *opts.(*arvados.UpdateOptions))
                        },
                },
                {
                        arvados.EndpointUserList,
                        func() interface{} { return &arvados.ListOptions{Limit: -1} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.UserList(ctx, *opts.(*arvados.ListOptions))
+                               return rtr.backend.UserList(ctx, *opts.(*arvados.ListOptions))
                        },
                },
                {
                        arvados.EndpointUserBatchUpdate,
                        func() interface{} { return &arvados.UserBatchUpdateOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.UserBatchUpdate(ctx, *opts.(*arvados.UserBatchUpdateOptions))
+                               return rtr.backend.UserBatchUpdate(ctx, *opts.(*arvados.UserBatchUpdateOptions))
                        },
                },
                {
                        arvados.EndpointUserDelete,
                        func() interface{} { return &arvados.DeleteOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.UserDelete(ctx, *opts.(*arvados.DeleteOptions))
+                               return rtr.backend.UserDelete(ctx, *opts.(*arvados.DeleteOptions))
                        },
                },
                {
                        arvados.EndpointUserAuthenticate,
                        func() interface{} { return &arvados.UserAuthenticateOptions{} },
                        func(ctx context.Context, opts interface{}) (interface{}, error) {
-                               return rtr.fed.UserAuthenticate(ctx, *opts.(*arvados.UserAuthenticateOptions))
+                               return rtr.backend.UserAuthenticate(ctx, *opts.(*arvados.UserAuthenticateOptions))
                        },
                },
        } {
-               rtr.addRoute(route.endpoint, route.defaultOpts, route.exec)
+               exec := route.exec
+               if rtr.wrapCalls != nil {
+                       exec = rtr.wrapCalls(exec)
+               }
+               rtr.addRoute(route.endpoint, route.defaultOpts, exec)
        }
        rtr.mux.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
                httpserver.Errors(w, []string{"API endpoint not found"}, http.StatusNotFound)
@@ -326,7 +340,7 @@ var altMethod = map[string]string{
        "GET":   "HEAD", // Accept HEAD at any GET route
 }
 
-func (rtr *router) addRoute(endpoint arvados.APIEndpoint, defaultOpts func() interface{}, exec routableFunc) {
+func (rtr *router) addRoute(endpoint arvados.APIEndpoint, defaultOpts func() interface{}, exec RoutableFunc) {
        methods := []string{endpoint.Method}
        if alt, ok := altMethod[endpoint.Method]; ok {
                methods = append(methods, alt)
index 4cabe70f162a6f36360da58f7c820e1712e0728f..c73bc64915f12aff293f23c803e25771669fe8a9 100644 (file)
@@ -38,8 +38,8 @@ type RouterSuite struct {
 func (s *RouterSuite) SetUpTest(c *check.C) {
        s.stub = arvadostest.APIStub{}
        s.rtr = &router{
-               mux: mux.NewRouter(),
-               fed: &s.stub,
+               mux:     mux.NewRouter(),
+               backend: &s.stub,
        }
        s.rtr.addRoutes()
 }
@@ -169,7 +169,7 @@ func (s *RouterIntegrationSuite) SetUpTest(c *check.C) {
        cluster.TLS.Insecure = true
        arvadostest.SetServiceURL(&cluster.Services.RailsAPI, "https://"+os.Getenv("ARVADOS_TEST_API_HOST"))
        url, _ := url.Parse("https://" + os.Getenv("ARVADOS_TEST_API_HOST"))
-       s.rtr = New(rpc.NewConn("zzzzz", url, true, rpc.PassthroughTokenProvider))
+       s.rtr = New(rpc.NewConn("zzzzz", url, true, rpc.PassthroughTokenProvider), nil)
 }
 
 func (s *RouterIntegrationSuite) TearDownSuite(c *check.C) {
diff --git a/lib/pam/.gitignore b/lib/pam/.gitignore
new file mode 100644 (file)
index 0000000..8d44d63
--- /dev/null
@@ -0,0 +1,2 @@
+pam_arvados.h
+pam_arvados.so
diff --git a/lib/pam/README b/lib/pam/README
new file mode 100644 (file)
index 0000000..8c5e10e
--- /dev/null
@@ -0,0 +1,18 @@
+For configuration advice, please refer to https://doc.arvados.org/install/install-webshell.html
+
+Usage (in pam config):
+
+    pam_arvados.so arvados_api_host my_vm_hostname ["insecure"] ["debug"]
+
+pam_arvados.so passes authentication if (according to
+arvados_api_host) the supplied PAM token belongs to an Arvados user
+who is allowed to log in to my_vm_host_name with the supplied PAM
+username.
+
+If my_vm_hostname is omitted or "-", the current hostname is used.
+
+"insecure" -- continue even if the TLS certificate presented by
+arvados_api_host fails verification.
+
+"debug" -- enable debug-level log messages in syslog and (when not in
+"silent" mode) on the calling application's stderr.
diff --git a/lib/pam/docker_test.go b/lib/pam/docker_test.go
new file mode 100644 (file)
index 0000000..fa16b31
--- /dev/null
@@ -0,0 +1,173 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+       "bytes"
+       "crypto/tls"
+       "fmt"
+       "io/ioutil"
+       "net"
+       "net/http"
+       "net/http/httputil"
+       "net/url"
+       "os"
+       "os/exec"
+       "strings"
+       "testing"
+
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "gopkg.in/check.v1"
+)
+
+type DockerSuite struct {
+       tmpdir   string
+       hostip   string
+       proxyln  net.Listener
+       proxysrv *http.Server
+}
+
+var _ = check.Suite(&DockerSuite{})
+
+func Test(t *testing.T) { check.TestingT(t) }
+
+func (s *DockerSuite) SetUpSuite(c *check.C) {
+       if testing.Short() {
+               c.Skip("skipping docker tests in short mode")
+       } else if _, err := exec.Command("docker", "info").CombinedOutput(); err != nil {
+               c.Skip("skipping docker tests because docker is not available")
+       }
+
+       s.tmpdir = c.MkDir()
+
+       // The integration-testing controller listens on the loopback
+       // interface, so it won't be reachable directly from the
+       // docker container -- so here we run a proxy on 0.0.0.0 for
+       // the duration of the test.
+       hostips, err := exec.Command("hostname", "-I").Output()
+       c.Assert(err, check.IsNil)
+       s.hostip = strings.Split(strings.Trim(string(hostips), "\n"), " ")[0]
+       ln, err := net.Listen("tcp", s.hostip+":0")
+       c.Assert(err, check.IsNil)
+       s.proxyln = ln
+       proxy := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "https", Host: os.Getenv("ARVADOS_API_HOST")})
+       proxy.Transport = &http.Transport{
+               TLSClientConfig: &tls.Config{
+                       InsecureSkipVerify: true,
+               },
+       }
+       s.proxysrv = &http.Server{Handler: proxy}
+       go s.proxysrv.ServeTLS(ln, "../../services/api/tmp/self-signed.pem", "../../services/api/tmp/self-signed.key")
+
+       // Build a pam module to install & configure in the docker
+       // container.
+       cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", s.tmpdir+"/pam_arvados.so")
+       cmd.Stdout = os.Stdout
+       cmd.Stderr = os.Stderr
+       err = cmd.Run()
+       c.Assert(err, check.IsNil)
+
+       // Build the testclient program that will (from inside the
+       // docker container) configure the system to use the above PAM
+       // config, and then try authentication.
+       cmd = exec.Command("go", "build", "-o", s.tmpdir+"/testclient", "./testclient.go")
+       cmd.Stdout = os.Stdout
+       cmd.Stderr = os.Stderr
+       err = cmd.Run()
+       c.Assert(err, check.IsNil)
+}
+
+func (s *DockerSuite) TearDownSuite(c *check.C) {
+       if s.proxysrv != nil {
+               s.proxysrv.Close()
+       }
+       if s.proxyln != nil {
+               s.proxyln.Close()
+       }
+}
+
+func (s *DockerSuite) SetUpTest(c *check.C) {
+       // Write a PAM config file that uses our proxy as
+       // ARVADOS_API_HOST.
+       proxyhost := s.proxyln.Addr().String()
+       confdata := fmt.Sprintf(`Name: Arvados authentication
+Default: yes
+Priority: 256
+Auth-Type: Primary
+Auth:
+       [success=end default=ignore]    /usr/lib/pam_arvados.so %s testvm2.shell insecure
+Auth-Initial:
+       [success=end default=ignore]    /usr/lib/pam_arvados.so %s testvm2.shell insecure
+`, proxyhost, proxyhost)
+       err := ioutil.WriteFile(s.tmpdir+"/conffile", []byte(confdata), 0755)
+       c.Assert(err, check.IsNil)
+}
+
+func (s *DockerSuite) runTestClient(c *check.C, args ...string) (stdout, stderr *bytes.Buffer, err error) {
+
+       cmd := exec.Command("docker", append([]string{
+               "run", "--rm",
+               "--hostname", "testvm2.shell",
+               "--add-host", "zzzzz.arvadosapi.com:" + s.hostip,
+               "-v", s.tmpdir + "/pam_arvados.so:/usr/lib/pam_arvados.so:ro",
+               "-v", s.tmpdir + "/conffile:/usr/share/pam-configs/arvados:ro",
+               "-v", s.tmpdir + "/testclient:/testclient:ro",
+               "debian:buster",
+               "/testclient"}, args...)...)
+       stdout = &bytes.Buffer{}
+       stderr = &bytes.Buffer{}
+       cmd.Stdout = stdout
+       cmd.Stderr = stderr
+       err = cmd.Run()
+       return
+}
+
+func (s *DockerSuite) TestSuccess(c *check.C) {
+       stdout, stderr, err := s.runTestClient(c, "try", "active", arvadostest.ActiveTokenV2)
+       c.Check(err, check.IsNil)
+       c.Logf("%s", stderr.String())
+       c.Check(stdout.String(), check.Equals, "")
+       c.Check(stderr.String(), check.Matches, `(?ms).*authentication succeeded.*`)
+}
+
+func (s *DockerSuite) TestFailure(c *check.C) {
+       for _, trial := range []struct {
+               label    string
+               username string
+               token    string
+       }{
+               {"bad token", "active", arvadostest.ActiveTokenV2 + "badtoken"},
+               {"empty token", "active", ""},
+               {"empty username", "", arvadostest.ActiveTokenV2},
+               {"wrong username", "wrongusername", arvadostest.ActiveTokenV2},
+       } {
+               c.Logf("trial: %s", trial.label)
+               stdout, stderr, err := s.runTestClient(c, "try", trial.username, trial.token)
+               c.Logf("%s", stderr.String())
+               c.Check(err, check.NotNil)
+               c.Check(stdout.String(), check.Equals, "")
+               c.Check(stderr.String(), check.Matches, `(?ms).*authentication failed.*`)
+       }
+}
+
+func (s *DockerSuite) TestDefaultHostname(c *check.C) {
+       confdata := fmt.Sprintf(`Name: Arvados authentication
+Default: yes
+Priority: 256
+Auth-Type: Primary
+Auth:
+       [success=end default=ignore]    /usr/lib/pam_arvados.so %s - insecure debug
+Auth-Initial:
+       [success=end default=ignore]    /usr/lib/pam_arvados.so %s - insecure debug
+`, s.proxyln.Addr().String(), s.proxyln.Addr().String())
+       err := ioutil.WriteFile(s.tmpdir+"/conffile", []byte(confdata), 0755)
+       c.Assert(err, check.IsNil)
+
+       stdout, stderr, err := s.runTestClient(c, "try", "active", arvadostest.ActiveTokenV2)
+       c.Check(err, check.IsNil)
+       c.Logf("%s", stderr.String())
+       c.Check(stdout.String(), check.Equals, "")
+       c.Check(stderr.String(), check.Matches, `(?ms).*authentication succeeded.*`)
+}
diff --git a/lib/pam/fpm-info.sh b/lib/pam/fpm-info.sh
new file mode 100644 (file)
index 0000000..3366b8e
--- /dev/null
@@ -0,0 +1,5 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+fpm_depends+=(ca-certificates)
diff --git a/lib/pam/pam-configs-arvados b/lib/pam/pam-configs-arvados
new file mode 100644 (file)
index 0000000..37ed4b8
--- /dev/null
@@ -0,0 +1,19 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+# This file is packaged as /usr/share/pam-configs/arvados-go; see build/run-library.sh
+
+# 1. Run `pam-auth-update` and choose Arvados authentication
+# 2. In /etc/pam.d/common-auth, change "api.example" to your ARVADOS_API_HOST
+# 3. In /etc/pam.d/common-auth, change "shell.example" to this host's hostname
+#    (as it appears in the Arvados virtual_machines list)
+
+Name: Arvados authentication
+Default: yes
+Priority: 256
+Auth-Type: Primary
+Auth:
+       [success=end default=ignore]    /usr/lib/pam_arvados.so api.example shell.example
+Auth-Initial:
+       [success=end default=ignore]    /usr/lib/pam_arvados.so api.example shell.example
diff --git a/lib/pam/pam_arvados.go b/lib/pam/pam_arvados.go
new file mode 100644 (file)
index 0000000..34b9080
--- /dev/null
@@ -0,0 +1,185 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+// To enable, add an entry in /etc/pam.d/common-auth where pam_unix.so
+// would normally be. Examples:
+//
+// auth [success=1 default=ignore] /usr/lib/pam_arvados.so zzzzz.arvadosapi.com vmhostname.example
+// auth [success=1 default=ignore] /usr/lib/pam_arvados.so zzzzz.arvadosapi.com vmhostname.example insecure debug
+//
+// Replace zzzzz.arvadosapi.com with your controller host or
+// host:port.
+//
+// Replace vmhostname.example with the VM's name as it appears in the
+// Arvados virtual_machine object.
+//
+// Use "insecure" if your API server certificate does not pass name
+// verification.
+//
+// Use "debug" to enable debug log messages.
+
+package main
+
+import (
+       "io/ioutil"
+       "log/syslog"
+       "os"
+
+       "context"
+       "errors"
+       "fmt"
+       "runtime"
+       "syscall"
+       "time"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "github.com/sirupsen/logrus"
+       lSyslog "github.com/sirupsen/logrus/hooks/syslog"
+       "golang.org/x/sys/unix"
+)
+
+/*
+#cgo LDFLAGS: -lpam -fPIC
+#include <security/pam_ext.h>
+char *stringindex(char** a, int i);
+const char *get_user(pam_handle_t *pamh);
+const char *get_authtoken(pam_handle_t *pamh);
+*/
+import "C"
+
+func main() {}
+
+func init() {
+       if err := unix.Prctl(syscall.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil {
+               newLogger(false).WithError(err).Warn("unable to disable ptrace")
+       }
+}
+
+//export pam_sm_setcred
+func pam_sm_setcred(pamh *C.pam_handle_t, flags, cArgc C.int, cArgv **C.char) C.int {
+       return C.PAM_IGNORE
+}
+
+//export pam_sm_authenticate
+func pam_sm_authenticate(pamh *C.pam_handle_t, flags, cArgc C.int, cArgv **C.char) C.int {
+       runtime.GOMAXPROCS(1)
+       logger := newLogger(flags&C.PAM_SILENT == 0)
+       cUsername := C.get_user(pamh)
+       if cUsername == nil {
+               return C.PAM_USER_UNKNOWN
+       }
+
+       cToken := C.get_authtoken(pamh)
+       if cToken == nil {
+               return C.PAM_AUTH_ERR
+       }
+
+       argv := make([]string, cArgc)
+       for i := 0; i < int(cArgc); i++ {
+               argv[i] = C.GoString(C.stringindex(cArgv, C.int(i)))
+       }
+
+       err := authenticate(logger, C.GoString(cUsername), C.GoString(cToken), argv)
+       if err != nil {
+               logger.WithError(err).Error("authentication failed")
+               return C.PAM_AUTH_ERR
+       }
+       return C.PAM_SUCCESS
+}
+
+func authenticate(logger *logrus.Logger, username, token string, argv []string) error {
+       hostname := ""
+       apiHost := ""
+       insecure := false
+       for idx, arg := range argv {
+               if idx == 0 {
+                       apiHost = arg
+               } else if idx == 1 {
+                       hostname = arg
+               } else if arg == "insecure" {
+                       insecure = true
+               } else if arg == "debug" {
+                       logger.SetLevel(logrus.DebugLevel)
+               } else {
+                       logger.Warnf("unkown option: %s\n", arg)
+               }
+       }
+       if hostname == "" || hostname == "-" {
+               h, err := os.Hostname()
+               if err != nil {
+                       logger.WithError(err).Warnf("cannot get hostname -- try using an explicit hostname in pam config")
+                       return fmt.Errorf("cannot get hostname: %w", err)
+               }
+               hostname = h
+       }
+       logger.Debugf("username=%q arvados_api_host=%q hostname=%q insecure=%t", username, apiHost, hostname, insecure)
+       if apiHost == "" {
+               logger.Warnf("cannot authenticate: config error: arvados_api_host and hostname must be non-empty")
+               return errors.New("config error")
+       }
+       arv := &arvados.Client{
+               Scheme:    "https",
+               APIHost:   apiHost,
+               AuthToken: token,
+               Insecure:  insecure,
+       }
+       ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Minute))
+       defer cancel()
+       var vms arvados.VirtualMachineList
+       err := arv.RequestAndDecodeContext(ctx, &vms, "GET", "arvados/v1/virtual_machines", nil, arvados.ListOptions{
+               Limit: 2,
+               Filters: []arvados.Filter{
+                       {"hostname", "=", hostname},
+               },
+       })
+       if err != nil {
+               return err
+       }
+       if len(vms.Items) == 0 {
+               // It's possible there is no VM entry for the
+               // configured hostname, but typically this just means
+               // the user does not have permission to see (let alone
+               // log in to) this VM.
+               return errors.New("permission denied")
+       } else if len(vms.Items) > 1 {
+               return fmt.Errorf("multiple results for hostname %q", hostname)
+       } else if vms.Items[0].Hostname != hostname {
+               return fmt.Errorf("looked up hostname %q but controller returned record with hostname %q", hostname, vms.Items[0].Hostname)
+       }
+       var user arvados.User
+       err = arv.RequestAndDecodeContext(ctx, &user, "GET", "arvados/v1/users/current", nil, nil)
+       if err != nil {
+               return err
+       }
+       var links arvados.LinkList
+       err = arv.RequestAndDecodeContext(ctx, &links, "GET", "arvados/v1/links", nil, arvados.ListOptions{
+               Limit: 1,
+               Filters: []arvados.Filter{
+                       {"link_class", "=", "permission"},
+                       {"name", "=", "can_login"},
+                       {"tail_uuid", "=", user.UUID},
+                       {"head_uuid", "=", vms.Items[0].UUID},
+                       {"properties.username", "=", username},
+               },
+       })
+       if err != nil {
+               return err
+       }
+       if len(links.Items) < 1 || links.Items[0].Properties["username"] != username {
+               return errors.New("permission denied")
+       }
+       logger.Debugf("permission granted based on link with UUID %s", links.Items[0].UUID)
+       return nil
+}
+
+func newLogger(stderr bool) *logrus.Logger {
+       logger := logrus.New()
+       if !stderr {
+               logger.Out = ioutil.Discard
+       }
+       if hook, err := lSyslog.NewSyslogHook("udp", "localhost:514", syslog.LOG_AUTH|syslog.LOG_INFO, "pam_arvados"); err != nil {
+               logger.Hooks.Add(hook)
+       }
+       return logger
+}
diff --git a/lib/pam/pam_c.go b/lib/pam/pam_c.go
new file mode 100644 (file)
index 0000000..4bf975b
--- /dev/null
@@ -0,0 +1,24 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+/*
+#cgo LDFLAGS: -lpam -fPIC
+#include <security/pam_ext.h>
+char *stringindex(char** a, int i) { return a[i]; }
+const char *get_user(pam_handle_t *pamh) {
+  const char *user;
+  if (pam_get_item(pamh, PAM_USER, (const void**)&user) != PAM_SUCCESS)
+    return NULL;
+  return user;
+}
+const char *get_authtoken(pam_handle_t *pamh) {
+  const char *token;
+  if (pam_get_authtok(pamh, PAM_AUTHTOK, &token, NULL) != PAM_SUCCESS)
+    return NULL;
+  return token;
+}
+*/
+import "C"
diff --git a/lib/pam/testclient.go b/lib/pam/testclient.go
new file mode 100644 (file)
index 0000000..3e92cac
--- /dev/null
@@ -0,0 +1,83 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+// +build never
+
+// This file is compiled by docker_test.go to build a test client.
+// It's not part of the pam module itself.
+
+package main
+
+import (
+       "fmt"
+       "os"
+       "os/exec"
+
+       "github.com/msteinert/pam"
+       "github.com/sirupsen/logrus"
+)
+
+func main() {
+       if len(os.Args) != 4 || os.Args[1] != "try" {
+               logrus.Print("usage: testclient try 'username' 'password'")
+               os.Exit(1)
+       }
+       username := os.Args[2]
+       password := os.Args[3]
+
+       // Configure PAM to use arvados token auth by default.
+       cmd := exec.Command("pam-auth-update", "--force", "arvados", "--remove", "unix")
+       cmd.Env = append([]string{"DEBIAN_FRONTEND=noninteractive"}, os.Environ()...)
+       cmd.Stdin = nil
+       cmd.Stdout = os.Stdout
+       cmd.Stderr = os.Stderr
+       err := cmd.Run()
+       if err != nil {
+               logrus.WithError(err).Error("pam-auth-update failed")
+               os.Exit(1)
+       }
+
+       // Check that pam-auth-update actually added arvados config.
+       cmd = exec.Command("grep", "-Hn", "arvados", "/etc/pam.d/common-auth")
+       cmd.Stdout = os.Stderr
+       cmd.Stderr = os.Stderr
+       err = cmd.Run()
+       if err != nil {
+               panic(err)
+       }
+
+       logrus.Debugf("starting pam: username=%q password=%q", username, password)
+
+       sentPassword := false
+       errorMessage := ""
+       tx, err := pam.StartFunc("default", username, func(style pam.Style, message string) (string, error) {
+               logrus.Debugf("pam conversation: style=%v message=%q", style, message)
+               switch style {
+               case pam.ErrorMsg:
+                       logrus.WithField("Message", message).Info("pam.ErrorMsg")
+                       errorMessage = message
+                       return "", nil
+               case pam.TextInfo:
+                       logrus.WithField("Message", message).Info("pam.TextInfo")
+                       errorMessage = message
+                       return "", nil
+               case pam.PromptEchoOn, pam.PromptEchoOff:
+                       sentPassword = true
+                       return password, nil
+               default:
+                       return "", fmt.Errorf("unrecognized message style %d", style)
+               }
+       })
+       if err != nil {
+               logrus.WithError(err).Print("StartFunc failed")
+               os.Exit(1)
+       }
+       err = tx.Authenticate(pam.DisallowNullAuthtok)
+       if err != nil {
+               err = fmt.Errorf("PAM: %s (message = %q)", err, errorMessage)
+               logrus.WithError(err).Print("authentication failed")
+               os.Exit(1)
+       }
+       logrus.Print("authentication succeeded")
+}
index 593129bb3ceeb18bbc6cb2520529fd5067b823b1..6c33f97913f83aaeaebf392fec740ba9f9d0d98a 100644 (file)
@@ -15,5 +15,11 @@ if (!requireNamespace("knitr")) {
 if (!requireNamespace("markdown")) {
   install.packages("markdown")
 }
+if (!requireNamespace("XML")) {
+  # XML 3.99-0.4 depends on R >= 4.0.0, but we run tests on debian
+  # stable (10) with R 3.5.2 so we install an older version from
+  # source.
+  install.packages("https://cran.r-project.org/src/contrib/Archive/XML/XML_3.99-0.3.tar.gz", repos=NULL, type="source")
+}
 
 devtools::install_dev_deps()
index a47b34acc0b31d304e56f98d89a6d277e598c9ea..71e499ebcab0cca29ccbee7a350cfbbb5aaa6e19 100644 (file)
@@ -460,6 +460,8 @@ def packed_workflow(arvrunner, tool, merged_map):
     def visit(v, cur_id):
         if isinstance(v, dict):
             if v.get("class") in ("CommandLineTool", "Workflow"):
+                if tool.metadata["cwlVersion"] == "v1.0" and "id" not in v:
+                    raise SourceLine(v, None, Exception).makeError("Embedded process object is missing required 'id' field, add an 'id' or use to cwlVersion: v1.1")
                 if "id" in v:
                     cur_id = rewrite_to_orig.get(v["id"], v["id"])
             if "path" in v and "location" not in v:
index df9fac8426cc450f0dc7014c72d0e799863657cb..c4c0968756a46b04ad8b201cbc66241fb4d6826d 100644 (file)
   output:
     out: null
   tool: wf-defaults/wf4.cwl
-  doc: default in embedded subworkflow missing 'id' field
+  doc: default in embedded subworkflow missing 'id' field, v1.0
   should_fail: true
 
+- job: null
+  output:
+    out: null
+  tool: wf-defaults/wf8.cwl
+  doc: default in embedded subworkflow missing 'id' field, v1.1
+  should_fail: false
+
 - job: null
   output:
     out: null
index 8bfc5d63f744a784e13d78ec48049211ae629c48..bd927824886d1805cf8daf260e4911e6b4fe2d85 100644 (file)
@@ -21,4 +21,4 @@ steps:
             class: Directory
             location: inp1
       outputs: []
-      arguments: [echo, $(inputs.inp2)]
\ No newline at end of file
+      arguments: [echo, $(inputs.inp2)]
diff --git a/sdk/cwl/tests/wf-defaults/default-dir8.cwl b/sdk/cwl/tests/wf-defaults/default-dir8.cwl
new file mode 100644 (file)
index 0000000..a5b9c2f
--- /dev/null
@@ -0,0 +1,24 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+cwlVersion: v1.1
+class: Workflow
+inputs: []
+outputs: []
+$namespaces:
+  arv: "http://arvados.org/cwl#"
+steps:
+  step1:
+    in: []
+    out: []
+    run:
+      class: CommandLineTool
+      inputs:
+        inp2:
+          type: Directory
+          default:
+            class: Directory
+            location: inp1
+      outputs: []
+      arguments: [echo, $(inputs.inp2)]
index 6e562e43dbd791f390dd25f6803e4a23c49ce967..3f498fdffbfa56100c721f6efb78efcb40267f74 100644 (file)
@@ -14,4 +14,4 @@ steps:
   step1:
     in: []
     out: []
-    run: default-dir4.cwl
\ No newline at end of file
+    run: default-dir4.cwl
diff --git a/sdk/cwl/tests/wf-defaults/wf8.cwl b/sdk/cwl/tests/wf-defaults/wf8.cwl
new file mode 100644 (file)
index 0000000..2548fae
--- /dev/null
@@ -0,0 +1,17 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+cwlVersion: v1.1
+class: Workflow
+inputs: []
+outputs: []
+$namespaces:
+  arv: "http://arvados.org/cwl#"
+requirements:
+  SubworkflowFeatureRequirement: {}
+steps:
+  step1:
+    in: []
+    out: []
+    run: default-dir8.cwl
index fbd699f30653035ff8c23ad1f62223c5ca54adc9..fdddfc537d8ee3b1dca86853232dc7017851969b 100644 (file)
@@ -6,14 +6,15 @@ package arvados
 
 // Link is an arvados#link record
 type Link struct {
-       UUID      string `json:"uuid,omiempty"`
-       OwnerUUID string `json:"owner_uuid"`
-       Name      string `json:"name"`
-       LinkClass string `json:"link_class"`
-       HeadUUID  string `json:"head_uuid"`
-       HeadKind  string `json:"head_kind"`
-       TailUUID  string `json:"tail_uuid"`
-       TailKind  string `json:"tail_kind"`
+       UUID       string                 `json:"uuid,omiempty"`
+       OwnerUUID  string                 `json:"owner_uuid"`
+       Name       string                 `json:"name"`
+       LinkClass  string                 `json:"link_class"`
+       HeadUUID   string                 `json:"head_uuid"`
+       HeadKind   string                 `json:"head_kind"`
+       TailUUID   string                 `json:"tail_uuid"`
+       TailKind   string                 `json:"tail_kind"`
+       Properties map[string]interface{} `json:"properties"`
 }
 
 // UserList is an arvados#userList resource.
diff --git a/sdk/go/arvados/virtual_machine.go b/sdk/go/arvados/virtual_machine.go
new file mode 100644 (file)
index 0000000..1506ede
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import "time"
+
+// VirtualMachine is an arvados#virtualMachine resource.
+type VirtualMachine struct {
+       UUID               string     `json:"uuid"`
+       OwnerUUID          string     `json:"owner_uuid"`
+       Hostname           string     `json:"hostname"`
+       CreatedAt          *time.Time `json:"created_at"`
+       ModifiedAt         *time.Time `json:"modified_at"`
+       ModifiedByUserUUID string     `json:"modified_by_user_uuid"`
+}
+
+// VirtualMachineList is an arvados#virtualMachineList resource.
+type VirtualMachineList struct {
+       Items          []VirtualMachine `json:"items"`
+       ItemsAvailable int              `json:"items_available"`
+       Offset         int              `json:"offset"`
+       Limit          int              `json:"limit"`
+}