--- /dev/null
+*~
+*.pyc
+.eggs
+*.egg_info
+build
+tmp
--- /dev/null
+../python/.gitignore
\ No newline at end of file
--- /dev/null
+# 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
--- /dev/null
+include README.rst
+include lib/libpam_arvados.py
+include pam-configs/arvados
--- /dev/null
+==================
+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.
+++ /dev/null
-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
--- /dev/null
+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
--- /dev/null
+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
+++ /dev/null
-# 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
+++ /dev/null
-#
-# 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.)
--- /dev/null
+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
--- /dev/null
+# 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
--- /dev/null
+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')
--- /dev/null
+../python/gittaggers.py
\ No newline at end of file
--- /dev/null
+"""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'))
--- /dev/null
+from arvados_pam import *
--- /dev/null
+# 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
--- /dev/null
+#!/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},
+ )
--- /dev/null
+#!/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;
+}
--- /dev/null
+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)
--- /dev/null
+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())
--- /dev/null
+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']