Merge branch '6934-pam' refs #6934
authorTom Clegg <tom@curoverse.com>
Thu, 13 Aug 2015 03:04:35 +0000 (23:04 -0400)
committerTom Clegg <tom@curoverse.com>
Thu, 13 Aug 2015 03:04:35 +0000 (23:04 -0400)
25 files changed:
sdk/pam/.dockerignore [new file with mode: 0644]
sdk/pam/.gitignore [new symlink]
sdk/pam/Dockerfile [new file with mode: 0644]
sdk/pam/MANIFEST.in [new file with mode: 0644]
sdk/pam/README.rst [new file with mode: 0644]
sdk/pam/arvados_pam.py [deleted file]
sdk/pam/arvados_pam/__init__.py [new file with mode: 0644]
sdk/pam/arvados_pam/auth_event.py [new file with mode: 0644]
sdk/pam/debian/arvados_pam [deleted file]
sdk/pam/debian/shellinabox [deleted file]
sdk/pam/examples/etc_pam.d_arvados-pam-test [new file with mode: 0644]
sdk/pam/examples/etc_pam.d_shellinabox [new file with mode: 0644]
sdk/pam/fpm-info.sh [new file with mode: 0644]
sdk/pam/gittaggers.py [new symlink]
sdk/pam/integration_tests/__init__.py [new file with mode: 0644]
sdk/pam/integration_tests/test_pam.py [new file with mode: 0644]
sdk/pam/lib/libpam_arvados.py [new file with mode: 0644]
sdk/pam/pam-configs/arvados [new file with mode: 0644]
sdk/pam/setup.py [new file with mode: 0755]
sdk/pam/tests/__init__.py [new file with mode: 0644]
sdk/pam/tests/integration_test.pl [new file with mode: 0755]
sdk/pam/tests/mocker.py [new file with mode: 0644]
sdk/pam/tests/test_auth_event.py [new file with mode: 0644]
sdk/pam/tests/test_pam_sm.py [new file with mode: 0644]
services/nodemanager/bin/arvados-node-manager [changed mode: 0644->0755]

diff --git a/sdk/pam/.dockerignore b/sdk/pam/.dockerignore
new file mode 100644 (file)
index 0000000..86ec754
--- /dev/null
@@ -0,0 +1,6 @@
+*~
+*.pyc
+.eggs
+*.egg_info
+build
+tmp
diff --git a/sdk/pam/.gitignore b/sdk/pam/.gitignore
new file mode 120000 (symlink)
index 0000000..1399fd4
--- /dev/null
@@ -0,0 +1 @@
+../python/.gitignore
\ No newline at end of file
diff --git a/sdk/pam/Dockerfile b/sdk/pam/Dockerfile
new file mode 100644 (file)
index 0000000..5cee5cc
--- /dev/null
@@ -0,0 +1,52 @@
+# These tests assume you have a real API server running on the docker host.
+#
+# Build the test container:
+#   First, replace 3000 below with your api server's port number if necessary.
+#   host$ python setup.py sdist rotate --keep=1 --match .tar.gz
+#   host$ docker build --tag=arvados/pam_test .
+#
+# Automated integration test:
+#   host$ docker run -it --add-host zzzzz.arvadosapi.com:"$(hostname -I |awk '{print $1}')" arvados/pam_test
+# You should see "=== OK ===", followed by a Perl stack trace due to a
+# yet-unidentified pam_python.so bug.
+#
+# Manual integration test:
+#   host$ docker run -it --add-host zzzzz.arvadosapi.com:"$(hostname -I |awk '{print $1}')" arvados/pam_test bash -c 'rsyslogd & tail -F /var/log/auth.log & sleep 1 & bash'
+#   container# login
+#   login: active
+#   Arvados API token: 3kg6k6lzmp9kj5cpkcoxie963cmvjahbt2fod9zru30k1jqdmi
+# You should now be logged in to the "active" shell account in the
+# container. You should also see arvados_pam log entries in
+# /var/log/auth.log (and in your terminal, thanks to "tail -F").
+
+FROM debian:wheezy
+RUN apt-get update
+RUN apt-get -qy dist-upgrade
+RUN apt-get -qy install python python-virtualenv libpam-python rsyslog
+# Packages required by pycurl, ciso8601
+RUN apt-get -qy install libcurl4-gnutls-dev python2.7-dev
+
+# for jessie (which also has other snags)
+# RUN apt-get -qy install python-pip libgnutls28-dev
+
+RUN pip install --upgrade setuptools
+RUN pip install python-pam
+ADD dist /dist
+RUN pip install /dist/arvados-pam-*.tar.gz
+
+# Configure and enable the module (hopefully vendor packages will offer a neater way)
+RUN perl -pi -e 's{api.example}{zzzzz.arvadosapi.com:3000}; s{shell\.example}{testvm2.shell insecure};' /usr/share/pam-configs/arvados
+RUN DEBIAN_FRONTEND=noninteractive pam-auth-update arvados --remove unix
+
+# Add a user account matching the fixture
+RUN useradd -ms /bin/bash active
+
+# Test with python (SIGSEGV during tests)
+#ADD . /pam
+#WORKDIR /pam
+#CMD rsyslogd & tail -F /var/log/auth.log & python setup.py test --test-suite integration_tests
+
+# Test with perl (SIGSEGV when program exits)
+RUN apt-get install -qy libauthen-pam-perl
+ADD tests/integration_test.pl /integration_test.pl
+CMD rsyslogd & tail -F /var/log/auth.log & sleep 1 && /integration_test.pl
diff --git a/sdk/pam/MANIFEST.in b/sdk/pam/MANIFEST.in
new file mode 100644 (file)
index 0000000..39211b3
--- /dev/null
@@ -0,0 +1,3 @@
+include README.rst
+include lib/libpam_arvados.py
+include pam-configs/arvados
diff --git a/sdk/pam/README.rst b/sdk/pam/README.rst
new file mode 100644 (file)
index 0000000..fdf1f8e
--- /dev/null
@@ -0,0 +1,21 @@
+==================
+Arvados PAM Module
+==================
+
+Overview
+--------
+
+Accept Arvados API tokens to authenticate to shell accounts.
+
+.. _Arvados: https://arvados.org
+
+Installation
+------------
+
+See http://doc.arvados.org
+
+Testing and Development
+-----------------------
+
+https://arvados.org/projects/arvados/wiki/Hacking
+describes how to set up a development environment and run tests.
diff --git a/sdk/pam/arvados_pam.py b/sdk/pam/arvados_pam.py
deleted file mode 100644 (file)
index b38e54f..0000000
+++ /dev/null
@@ -1,100 +0,0 @@
-import syslog
-import sys
-sys.argv=['']
-import arvados
-import os
-
-def auth_log(msg):
- """Send errors to default auth log"""
- syslog.openlog(facility=syslog.LOG_AUTH)
- #syslog.openlog()
- syslog.syslog("libpam python Logged: " + msg)
- syslog.closelog()
-
-
-def check_arvados_token(requested_username, token):
-    auth_log("%s %s" % (requested_username, token))
-
-    try:
-        f=file('/etc/default/arvados_pam')
-        config=dict([l.split('=') for l in f.readlines() if not l.startswith('#') or l.strip()==""])
-        arvados_api_host=config['ARVADOS_API_HOST'].strip()
-        hostname=config['HOSTNAME'].strip()
-    except Exception as e:
-        auth_log("problem getting default values  %s" % e)
-        return False
-
-    try:
-        arv = arvados.api('v1',host=arvados_api_host, token=token, cache=None)
-    except Exception as e:
-        auth_log(str(e))
-        return False
-
-    try:
-        matches = arv.virtual_machines().list(filters=[['hostname','=',hostname]]).execute()['items']
-    except Exception as e:
-        auth_log(str(e))
-        return False
-
-
-    if len(matches) != 1:
-        auth_log("libpam_arvados could not determine vm uuid for '%s'" % hostname)
-        return False
-
-    this_vm_uuid = matches[0]['uuid']
-    auth_log("this_vm_uuid: %s" % this_vm_uuid)
-    client_user_uuid = arv.users().current().execute()['uuid']
-
-    filters = [
-            ['link_class','=','permission'],
-            ['name','=','can_login'],
-            ['head_uuid','=',this_vm_uuid],
-            ['tail_uuid','=',client_user_uuid]]
-
-    for l in arv.links().list(filters=filters).execute()['items']:
-         if requested_username == l['properties']['username']:
-             return  True
-    return False
-
-
-def pam_sm_authenticate(pamh, flags, argv):
- try:
-  user = pamh.get_user()
- except pamh.exception, e:
-  return e.pam_result
-
- if not user:
-  return pamh.PAM_USER_UNKNOWN
-
- try:
-  resp = pamh.conversation(pamh.Message(pamh.PAM_PROMPT_ECHO_OFF, ''))
- except pamh.exception, e:
-  return e.pam_result
-
- try:
-  check = check_arvados_token(user, resp.resp)
- except Exception as e:
-  auth_log(str(e))
-  return False
-
- if not check:
-  auth_log("Auth failed Remote Host: %s (%s:%s)" % (pamh.rhost, user, resp.resp))
-  return pamh.PAM_AUTH_ERR
-
- auth_log("Success! Remote Host: %s (%s:%s)" % (pamh.rhost, user, resp.resp))
- return pamh.PAM_SUCCESS
-
-def pam_sm_setcred(pamh, flags, argv):
- return pamh.PAM_SUCCESS
-
-def pam_sm_acct_mgmt(pamh, flags, argv):
- return pamh.PAM_SUCCESS
-
-def pam_sm_open_session(pamh, flags, argv):
- return pamh.PAM_SUCCESS
-
-def pam_sm_close_session(pamh, flags, argv):
- return pamh.PAM_SUCCESS
-
-def pam_sm_chauthtok(pamh, flags, argv):
- return pamh.PAM_SUCCESS
diff --git a/sdk/pam/arvados_pam/__init__.py b/sdk/pam/arvados_pam/__init__.py
new file mode 100644 (file)
index 0000000..087ea2e
--- /dev/null
@@ -0,0 +1,51 @@
+import sys
+sys.argv=['']
+
+from . import auth_event
+
+def pam_sm_authenticate(pamh, flags, argv):
+    config = {}
+    config['arvados_api_host'] = argv[1]
+    config['virtual_machine_hostname'] = argv[2]
+    if len(argv) > 3:
+        for k in argv[3:]:
+            config[k] = True
+
+    try:
+        username = pamh.get_user(None)
+    except pamh.exception, e:
+        return e.pam_result
+
+    if not username:
+        return pamh.PAM_USER_UNKNOWN
+
+    try:
+        prompt = '' if config.get('noprompt') else 'Arvados API token: '
+        token = pamh.conversation(pamh.Message(pamh.PAM_PROMPT_ECHO_OFF, prompt)).resp
+    except pamh.exception as e:
+        return e.pam_result
+
+    if auth_event.AuthEvent(
+            config=config,
+            service=pamh.service,
+            client_host=pamh.rhost,
+            username=username,
+            token=token).can_login():
+        return pamh.PAM_SUCCESS
+    else:
+        return pamh.PAM_AUTH_ERR
+
+def pam_sm_setcred(pamh, flags, argv):
+    return pamh.PAM_SUCCESS
+
+def pam_sm_acct_mgmt(pamh, flags, argv):
+    return pamh.PAM_SUCCESS
+
+def pam_sm_open_session(pamh, flags, argv):
+    return pamh.PAM_SUCCESS
+
+def pam_sm_close_session(pamh, flags, argv):
+    return pamh.PAM_SUCCESS
+
+def pam_sm_chauthtok(pamh, flags, argv):
+    return pamh.PAM_SUCCESS
diff --git a/sdk/pam/arvados_pam/auth_event.py b/sdk/pam/arvados_pam/auth_event.py
new file mode 100644 (file)
index 0000000..8abd9c5
--- /dev/null
@@ -0,0 +1,88 @@
+import arvados
+import syslog
+
+def auth_log(msg):
+    """Log an authentication result to syslogd"""
+    syslog.openlog(facility=syslog.LOG_AUTH)
+    syslog.syslog('arvados_pam: ' + msg)
+    syslog.closelog()
+
+class AuthEvent(object):
+    def __init__(self, config, service, client_host, username, token):
+        self.config = config
+        self.service = service
+        self.client_host = client_host
+        self.username = username
+        self.token = token
+
+        self.api_host = None
+        self.vm_uuid = None
+        self.user = None
+
+    def can_login(self):
+        """Return truthy IFF credentials should be accepted."""
+        ok = False
+        try:
+            self.api_host = self.config['arvados_api_host']
+            self.arv = arvados.api('v1', host=self.api_host, token=self.token,
+                                   insecure=self.config.get('insecure', False),
+                                   cache=False)
+
+            vmname = self.config['virtual_machine_hostname']
+            vms = self.arv.virtual_machines().list(filters=[['hostname','=',vmname]]).execute()
+            if vms['items_available'] > 1:
+                raise Exception("lookup hostname %s returned %d records" % (vmname, vms['items_available']))
+            if vms['items_available'] == 0:
+                raise Exception("lookup hostname %s not found" % vmname)
+            vm = vms['items'][0]
+            if vm['hostname'] != vmname:
+                raise Exception("lookup hostname %s returned hostname %s" % (vmname, vm['hostname']))
+            self.vm_uuid = vm['uuid']
+
+            self.user = self.arv.users().current().execute()
+
+            filters = [
+                ['link_class','=','permission'],
+                ['name','=','can_login'],
+                ['head_uuid','=',self.vm_uuid],
+                ['tail_uuid','=',self.user['uuid']]]
+            for l in self.arv.links().list(filters=filters, limit=10000).execute()['items']:
+                if (l['properties']['username'] == self.username and
+                    l['tail_uuid'] == self.user['uuid'] and
+                    l['head_uuid'] == self.vm_uuid and
+                    l['link_class'] == 'permission' and
+                    l['name'] == 'can_login'):
+                    return self._report(True)
+
+            return self._report(False)
+
+        except Exception as e:
+            return self._report(e)
+
+    def _report(self, result):
+        """Log the result. Return truthy IFF result is True.
+
+        result must be True, False, or an exception.
+        """
+        self.result = result
+        auth_log(self.message())
+        return result == True
+
+    def message(self):
+        """Return a log message describing the event and its outcome."""
+        if isinstance(self.result, Exception):
+            outcome = 'Error: ' + repr(self.result)
+        elif self.result == True:
+            outcome = 'Allow'
+        else:
+            outcome = 'Deny'
+
+        if len(self.token) > 40:
+            log_token = self.token[0:15]
+        else:
+            log_token = '<invalid>'
+
+        log_label = [self.service, self.api_host, self.vm_uuid, self.client_host, self.username, log_token]
+        if self.user:
+            log_label += [self.user.get('uuid'), self.user.get('full_name')]
+        return str(log_label) + ': ' + outcome
diff --git a/sdk/pam/debian/arvados_pam b/sdk/pam/debian/arvados_pam
deleted file mode 100644 (file)
index eae61d9..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-# Default values for libpam arvados module
-#
-# ARVADOS_API_HOST should be te api hosts.
-# should be reachable, and will be called
-# from arvados_pam.py using Arvados Python SDK
-ARVADOS_API_HOST=zzzzz.arvadosapi.com
-
-# HOSTNAME is the hostname as is stored in the API object
-# something like "foo.shell" or "shell", but not"foo.shell.zzzzz.arvadosapi.com"!
-HOSTNAME=shell
diff --git a/sdk/pam/debian/shellinabox b/sdk/pam/debian/shellinabox
deleted file mode 100644 (file)
index b983728..0000000
+++ /dev/null
@@ -1,136 +0,0 @@
-#
-# The PAM configuration file for the Shadow `login' service
-#
-
-# Enforce a minimal delay in case of failure (in microseconds).
-# (Replaces the `FAIL_DELAY' setting from login.defs)
-# Note that other modules may require another minimal delay. (for example,
-# to disable any delay, you should add the nodelay option to pam_unix)
-#auth       optional   pam_faildelay.so  delay=3000000
-auth       optional   pam_faildelay.so  delay=0
-
-# Outputs an issue file prior to each login prompt (Replaces the
-# ISSUE_FILE option from login.defs). Uncomment for use
-# auth       required   pam_issue.so issue=/etc/issue
-
-# Disallows root logins except on tty's listed in /etc/securetty
-# (Replaces the `CONSOLE' setting from login.defs)
-#
-# With the default control of this module:
-#   [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die]
-# root will not be prompted for a password on insecure lines.
-# if an invalid username is entered, a password is prompted (but login
-# will eventually be rejected)
-#
-# You can change it to a "requisite" module if you think root may mis-type
-# her login and should not be prompted for a password in that case. But
-# this will leave the system as vulnerable to user enumeration attacks.
-#
-# You can change it to a "required" module if you think it permits to
-# guess valid user names of your system (invalid user names are considered
-# as possibly being root on insecure lines), but root passwords may be
-# communicated over insecure lines.
-auth [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] pam_securetty.so
-
-# Disallows other than root logins when /etc/nologin exists
-# (Replaces the `NOLOGINS_FILE' option from login.defs)
-auth       requisite  pam_nologin.so
-
-# SELinux needs to be the first session rule. This ensures that any
-# lingering context has been cleared. Without out this it is possible
-# that a module could execute code in the wrong domain.
-# When the module is present, "required" would be sufficient (When SELinux
-# is disabled, this returns success.)
-session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close
-
-# This module parses environment configuration file(s)
-# and also allows you to use an extended config
-# file /etc/security/pam_env.conf.
-#
-# parsing /etc/environment needs "readenv=1"
-session       required   pam_env.so readenv=1
-# locale variables are also kept into /etc/default/locale in etch
-# reading this file *in addition to /etc/environment* does not hurt
-session       required   pam_env.so readenv=1 envfile=/etc/default/locale
-
-
-#
-# /etc/pam.d/common-auth - authentication settings common to all services
-#
-# This file is included from other service-specific PAM config files,
-# and should contain a list of the authentication modules that define
-# the central authentication scheme for use on the system
-# (e.g., /etc/shadow, LDAP, Kerberos, etc.).  The default is to use the
-# traditional Unix authentication mechanisms.
-#
-# As of pam 1.0.1-6, this file is managed by pam-auth-update by default.
-# To take advantage of this, it is recommended that you configure any
-# local modules either before or after the default block, and use
-# pam-auth-update to manage selection of other modules.  See
-# pam-auth-update(8) for details.
-
-# here are the per-package modules (the "Primary" block)
-auth  [success=1 default=ignore] pam_python.so /usr/bin/arvados_pam.py
-# here's the fallback if no module succeeds
-auth   requisite                       pam_deny.so
-# prime the stack with a positive return value if there isn't one already;
-# this avoids us returning an error just because nothing sets a success code
-# since the modules above will each just jump around
-auth   required                        pam_permit.so
-# and here are more per-package modules (the "Additional" block)
-auth   optional        pam_ecryptfs.so unwrap
-# end of pam-auth-update config
-
-# This allows certain extra groups to be granted to a user
-# based on things like time of day, tty, service, and user.
-# Please edit /etc/security/group.conf to fit your needs
-# (Replaces the `CONSOLE_GROUPS' option in login.defs)
-auth       optional   pam_group.so
-
-# Uncomment and edit /etc/security/time.conf if you need to set
-# time restrainst on logins.
-# (Replaces the `PORTTIME_CHECKS_ENAB' option from login.defs
-# as well as /etc/porttime)
-# account    requisite  pam_time.so
-
-# Uncomment and edit /etc/security/access.conf if you need to
-# set access limits.
-# (Replaces /etc/login.access file)
-# account  required       pam_access.so
-
-# Sets up user limits according to /etc/security/limits.conf
-# (Replaces the use of /etc/limits in old login)
-session    required   pam_limits.so
-
-# Prints the last login info upon succesful login
-# (Replaces the `LASTLOG_ENAB' option from login.defs)
-session    optional   pam_lastlog.so
-
-# Prints the message of the day upon succesful login.
-# (Replaces the `MOTD_FILE' option in login.defs)
-# This includes a dynamically generated part from /run/motd.dynamic
-# and a static (admin-editable) part from /etc/motd.
-session    optional   pam_motd.so  motd=/run/motd.dynamic
-session    optional   pam_motd.so
-
-# Prints the status of the user's mailbox upon succesful login
-# (Replaces the `MAIL_CHECK_ENAB' option from login.defs).
-#
-# This also defines the MAIL environment variable
-# However, userdel also needs MAIL_DIR and MAIL_FILE variables
-# in /etc/login.defs to make sure that removing a user
-# also removes the user's mail spool file.
-# See comments in /etc/login.defs
-session    optional   pam_mail.so standard
-
-# Standard Un*x account and session
-@include common-account
-@include common-session
-@include common-password
-
-# SELinux needs to intervene at login time to ensure that the process
-# starts in the proper default security context. Only sessions which are
-# intended to run in the user's context should be run after this.
-session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open
-# When the module is present, "required" would be sufficient (When SELinux
-# is disabled, this returns success.)
diff --git a/sdk/pam/examples/etc_pam.d_arvados-pam-test b/sdk/pam/examples/etc_pam.d_arvados-pam-test
new file mode 100644 (file)
index 0000000..ddff8c7
--- /dev/null
@@ -0,0 +1,3 @@
+auth [success=1 default=ignore] pam_python.so /usr/local/lib/python2.7/dist-packages/arvados_pam/__init__.py abc.example testvm2.shell
+auth   requisite                       pam_deny.so
+auth   required                        pam_permit.so
diff --git a/sdk/pam/examples/etc_pam.d_shellinabox b/sdk/pam/examples/etc_pam.d_shellinabox
new file mode 100644 (file)
index 0000000..355a85f
--- /dev/null
@@ -0,0 +1,25 @@
+# Install in /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
+auth       requisite  pam_nologin.so
+session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close
+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/local/lib/python2.7/dist-packages/arvados_pam/__init__.py api.example shell.example noprompt
+auth   requisite                       pam_deny.so
+auth   required                        pam_permit.so
+
+auth       optional   pam_group.so
+session    required   pam_limits.so
+session    optional   pam_lastlog.so
+session    optional   pam_motd.so  motd=/run/motd.dynamic
+session    optional   pam_motd.so
+session    optional   pam_mail.so standard
+
+@include common-account
+@include common-session
+@include common-password
+
+session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open
diff --git a/sdk/pam/fpm-info.sh b/sdk/pam/fpm-info.sh
new file mode 100644 (file)
index 0000000..0c259ea
--- /dev/null
@@ -0,0 +1,17 @@
+case "$TARGET" in
+    debian* | ubuntu*)
+        fpm_depends+=('libpam-python')
+        ;;
+    *)
+        echo >&2 "ERROR: $PACKAGE: pam_python.so dependency unavailable in $TARGET."
+        return 1
+        ;;
+esac
+
+case "$FORMAT" in
+    deb)
+        fpm_args+=('--deb-recommends=system-log-daemon')
+        ;;
+esac
+
+fpm_args+=('--config-files=examples/pam-auth-update_arvados')
diff --git a/sdk/pam/gittaggers.py b/sdk/pam/gittaggers.py
new file mode 120000 (symlink)
index 0000000..d59c02c
--- /dev/null
@@ -0,0 +1 @@
+../python/gittaggers.py
\ No newline at end of file
diff --git a/sdk/pam/integration_tests/__init__.py b/sdk/pam/integration_tests/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/sdk/pam/integration_tests/test_pam.py b/sdk/pam/integration_tests/test_pam.py
new file mode 100644 (file)
index 0000000..cfc915c
--- /dev/null
@@ -0,0 +1,26 @@
+"""These tests assume we are running (in a docker container) with
+arvados_pam configured and a test API server running.
+"""
+import pam
+import unittest
+
+# From services/api/test/fixtures/api_client_authorizations.yml
+# because that file is not available during integration tests:
+ACTIVE_TOKEN = '3kg6k6lzmp9kj5cpkcoxie963cmvjahbt2fod9zru30k1jqdmi'
+SPECTATOR_TOKEN = 'zw2f4gwx8hw8cjre7yp6v1zylhrhn3m5gvjq73rtpwhmknrybu'
+
+class IntegrationTest(unittest.TestCase):
+    def setUp(self):
+        self.p = pam.pam()
+
+    def test_allow(self):
+        self.assertTrue(self.p.authenticate('active', ACTIVE_TOKEN, service='login'))
+
+    def test_deny_bad_token(self):
+        self.assertFalse(self.p.authenticate('active', 'thisisaverybadtoken', service='login'))
+
+    def test_deny_empty_token(self):
+        self.assertFalse(self.p.authenticate('active', '', service='login'))
+
+    def test_deny_permission(self):
+        self.assertFalse(self.p.authenticate('spectator', SPECTATOR_TOKEN, service='login'))
diff --git a/sdk/pam/lib/libpam_arvados.py b/sdk/pam/lib/libpam_arvados.py
new file mode 100644 (file)
index 0000000..deead7e
--- /dev/null
@@ -0,0 +1 @@
+from arvados_pam import *
diff --git a/sdk/pam/pam-configs/arvados b/sdk/pam/pam-configs/arvados
new file mode 100644 (file)
index 0000000..6972a39
--- /dev/null
@@ -0,0 +1,14 @@
+# 1. Change "api.example" to your ARVADOS_API_HOST
+# 2. Change "shell.example" to this host's hostname
+#    (as it appears in the Arvados virtual_machines list)
+# 3. Install in /usr/share/pam-configs/arvados
+# 4. Run `pam-auth-update arvados`
+
+Name: Arvados authentication
+Default: yes
+Priority: 256
+Auth-Type: Primary
+Auth:
+       [success=end default=ignore]    pam_python.so /lib/security/libpam_arvados.py api.example shell.example
+Auth-Initial:
+       [success=end default=ignore]    pam_python.so /lib/security/libpam_arvados.py api.example shell.example
diff --git a/sdk/pam/setup.py b/sdk/pam/setup.py
new file mode 100755 (executable)
index 0000000..7ab4521
--- /dev/null
@@ -0,0 +1,45 @@
+#!/usr/bin/env python
+
+import os
+import sys
+import setuptools.command.egg_info as egg_info_cmd
+import subprocess
+
+from setuptools import setup, find_packages
+
+SETUP_DIR = os.path.dirname(__file__) or '.'
+README = os.path.join(SETUP_DIR, 'README.rst')
+
+tagger = egg_info_cmd.egg_info
+try:
+    import gittaggers
+    tagger = gittaggers.EggInfoFromGit
+except (ImportError, OSError):
+    pass
+
+setup(name='arvados-pam',
+      version='0.1',
+      description='Arvados PAM module',
+      long_description=open(README).read(),
+      author='Arvados',
+      author_email='info@arvados.org',
+      url='https://arvados.org',
+      download_url='https://github.com/curoverse/arvados.git',
+      license='Apache 2.0',
+      packages=[
+          'arvados_pam',
+      ],
+      scripts=[
+      ],
+      data_files=[
+          ('/usr/share/pam-configs', ['pam-configs/arvados']),
+          ('/lib/security', ['lib/libpam_arvados.py']),
+      ],
+      install_requires=[
+          'arvados-python-client>=0.1.20150801000000',
+      ],
+      test_suite='tests',
+      tests_require=['mock>=1.0', 'python-pam'],
+      zip_safe=False,
+      cmdclass={'egg_info': tagger},
+      )
diff --git a/sdk/pam/tests/__init__.py b/sdk/pam/tests/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/sdk/pam/tests/integration_test.pl b/sdk/pam/tests/integration_test.pl
new file mode 100755 (executable)
index 0000000..e5dff1e
--- /dev/null
@@ -0,0 +1,46 @@
+#!/usr/bin/env perl
+
+$ENV{ARVADOS_API_HOST_INSECURE} = 1;
+use Authen::PAM qw(:constants);
+
+for my $case (['good', 1, 'active', '3kg6k6lzmp9kj5cpkcoxie963cmvjahbt2fod9zru30k1jqdmi'],
+              ['badtoken', 0, 'active', 'badtokenmp9kj5cpkcoxie963cmvjahbt2fod9zru30k1jqdmi'],
+              ['badusername', 0, 'baduser', '3kg6k6lzmp9kj5cpkcoxie963cmvjahbt2fod9zru30k1jqdmi']) {
+    dotest(@$case);
+}
+print "=== OK ===\n";
+
+sub dotest {
+    my ($label, $expect_ok, $user, $token) = @_;
+    print "$label: ";
+    my $service_name = 'login';
+    $main::Token = $token;
+    my $pamh = new Authen::PAM($service_name, $user, \&token_conv_func);
+    ref($pamh) || die "Error code $pamh during PAM init!";
+    $pamh->pam_set_item(PAM_RHOST(), '::1');
+    $pamh->pam_set_item(PAM_RUSER(), 'none');
+    $pamh->pam_set_item(PAM_TTY(), '/dev/null');
+    my $flags = PAM_SILENT();
+    $res = $pamh->pam_authenticate($flags);
+    $msg = $pamh->pam_strerror($res);
+    print "Result (code $res): $msg\n";
+    if (($res == 0) != ($expect_ok == 1)) {
+        die "*** FAIL ***\n";
+    }
+}
+
+sub token_conv_func {
+    my @res;
+    while ( @_ ) {
+        my $code = shift;
+        my $msg = shift;
+        my $ans;
+        print "Message (type $code): $msg\n";
+        if ($code == PAM_PROMPT_ECHO_OFF() || $code == PAM_PROMPT_ECHO_ON()) {
+            $ans = $main::Token;
+        }
+        push @res, (0,$ans);
+    }
+    push @res, PAM_SUCCESS();
+    return @res;
+}
diff --git a/sdk/pam/tests/mocker.py b/sdk/pam/tests/mocker.py
new file mode 100644 (file)
index 0000000..76c1ea3
--- /dev/null
@@ -0,0 +1,59 @@
+import mock
+import unittest
+
+class Mocker(unittest.TestCase):
+    ACTIVE_TOKEN = '3kg6k6lzmp9kj5cpkcoxie963cmvjahbt2fod9zru30k1jqdmi'
+
+    default_config = {
+        'arvados_api_host': 'zzzzz.api_host.example',
+        'virtual_machine_hostname': 'testvm2.shell',
+    }
+    default_request = {
+        'client_host': '::1',
+        'token': ACTIVE_TOKEN,
+        'username': 'active',
+    }
+    default_response = {
+        'links': {
+            'items': [{
+                'uuid': 'zzzzz-o0j2j-rah2ya1ohx9xaev',
+                'tail_uuid': 'zzzzz-tpzed-xurymjxw79nv3jz',
+                'head_uuid': 'zzzzz-2x53u-382brsig8rp3065',
+                'link_class': 'permission',
+                'name': 'can_login',
+                'properties': {
+                    'username': 'active',
+                },
+            }],
+        },
+        'users': {
+            'uuid': 'zzzzz-tpzed-xurymjxw79nv3jz',
+            'full_name': 'Active User',
+        },
+        'virtual_machines': {
+            'items': [{
+                'uuid': 'zzzzz-2x53u-382brsig8rp3065',
+                'hostname': 'testvm2.shell',
+            }],
+            'items_available': 1,
+        },
+    }
+
+    def setUp(self):
+        self.config = self.default_config.copy()
+        self.request = self.default_request.copy()
+        self.response = self.default_response.copy()
+        self.api_client = mock.MagicMock(name='api_client')
+        self.api_client.users().current().execute.side_effect = lambda: self.response['users']
+        self.api_client.virtual_machines().list().execute.side_effect = lambda: self.response['virtual_machines']
+        self.api_client.links().list().execute.side_effect = lambda: self.response['links']
+        patcher = mock.patch('arvados.api')
+        self.api = patcher.start()
+        self.addCleanup(patcher.stop)
+        self.api.side_effect = [self.api_client]
+
+        self.syslogged = []
+        patcher = mock.patch('syslog.syslog')
+        self.syslog = patcher.start()
+        self.addCleanup(patcher.stop)
+        self.syslog.side_effect = lambda s: self.syslogged.append(s)
diff --git a/sdk/pam/tests/test_auth_event.py b/sdk/pam/tests/test_auth_event.py
new file mode 100644 (file)
index 0000000..3fb6d74
--- /dev/null
@@ -0,0 +1,95 @@
+import arvados_pam
+import re
+from . import mocker
+
+class AuthEventTest(mocker.Mocker):
+    def attempt(self):
+        return arvados_pam.auth_event.AuthEvent(config=self.config, service='test_service', **self.request).can_login()
+
+    def test_success(self):
+        self.assertTrue(self.attempt())
+
+        self.api_client.virtual_machines().list.assert_called_with(
+            filters=[['hostname','=',self.config['virtual_machine_hostname']]])
+        self.api.assert_called_with(
+            'v1',
+            host=self.config['arvados_api_host'], token=self.request['token'],
+            insecure=False,
+            cache=False)
+        self.assertEqual(1, len(self.syslogged))
+        for i in ['test_service',
+                  self.request['username'],
+                  self.config['arvados_api_host'],
+                  self.response['virtual_machines']['items'][0]['uuid']]:
+            self.assertRegexpMatches(self.syslogged[0], re.escape(i))
+        self.assertRegexpMatches(self.syslogged[0], re.escape(self.request['token'][0:15]), 'token prefix not logged')
+        self.assertNotRegexpMatches(self.syslogged[0], re.escape(self.request['token'][15:30]), 'too much token logged')
+
+    def test_fail_vm_lookup(self):
+        self.api_client.virtual_machines().list().execute.side_effect = Exception("Test-induced failure")
+        self.assertFalse(self.attempt())
+        self.assertRegexpMatches(self.syslogged[0], 'Test-induced failure')
+
+    def test_vm_hostname_not_found(self):
+        self.response['virtual_machines'] = {
+            'items': [],
+            'items_available': 0,
+        }
+        self.assertFalse(self.attempt())
+
+    def test_vm_hostname_ambiguous(self):
+        self.response['virtual_machines'] = {
+            'items': [
+                {
+                    'uuid': 'zzzzz-2x53u-382brsig8rp3065',
+                    'hostname': 'testvm2.shell',
+                },
+                {
+                    'uuid': 'zzzzz-2x53u-382brsig8rp3065',
+                    'hostname': 'testvm2.shell',
+                },
+            ],
+            'items_available': 2,
+        }
+        self.assertFalse(self.attempt())
+
+    def test_server_ignores_vm_filters(self):
+        self.response['virtual_machines'] = {
+            'items': [
+                {
+                    'uuid': 'zzzzz-2x53u-382brsig8rp3065',
+                    'hostname': 'testvm22.shell', # <-----
+                },
+            ],
+            'items_available': 1,
+        }
+        self.assertFalse(self.attempt())
+
+    def test_fail_user_lookup(self):
+        self.api_client.users().current().execute.side_effect = Exception("Test-induced failure")
+        self.assertFalse(self.attempt())
+
+    def test_fail_permission_check(self):
+        self.api_client.links().list().execute.side_effect = Exception("Test-induced failure")
+        self.assertFalse(self.attempt())
+
+    def test_no_login_permission(self):
+        self.response['links'] = {
+            'items': [],
+        }
+        self.assertFalse(self.attempt())
+
+    def test_server_ignores_permission_filters(self):
+        self.response['links'] = {
+            'items': [{
+                'uuid': 'zzzzz-o0j2j-rah2ya1ohx9xaev',
+                'tail_uuid': 'zzzzz-tpzed-xurymjxw79nv3jz',
+                'head_uuid': 'zzzzz-2x53u-382brsig8rp3065',
+                'link_class': 'permission',
+                'name': 'CANT_login', # <-----
+                'properties': {
+                    'username': 'active',
+                },
+            }],
+        }
+        self.assertFalse(self.attempt())
diff --git a/sdk/pam/tests/test_pam_sm.py b/sdk/pam/tests/test_pam_sm.py
new file mode 100644 (file)
index 0000000..a967493
--- /dev/null
@@ -0,0 +1,26 @@
+import arvados_pam
+import mock
+from . import mocker
+
+class PamSMTest(mocker.Mocker):
+    def attempt(self):
+        return arvados_pam.pam_sm_authenticate(self.pamh, 0, self.argv)
+
+    def test_success(self):
+        self.assertEqual(self.pamh.PAM_SUCCESS, self.attempt())
+
+    def test_bad_user(self):
+        self.pamh.get_user = mock.MagicMock(return_value='badusername')
+        self.assertEqual(self.pamh.PAM_AUTH_ERR, self.attempt())
+
+    def test_bad_vm(self):
+        self.argv[2] = 'testvm22.shell'
+        self.assertEqual(self.pamh.PAM_AUTH_ERR, self.attempt())
+
+    def setUp(self):
+        super(PamSMTest, self).setUp()
+        self.pamh = mock.MagicMock()
+        self.pamh.get_user = mock.MagicMock(return_value='active')
+        self.pamh.PAM_SUCCESS = 12345
+        self.pamh.PAM_AUTH_ERR = 54321
+        self.argv = [__file__, 'zzzzz.arvadosapi.com', 'testvm2.shell']
old mode 100644 (file)
new mode 100755 (executable)