From 4fc613797f88dbb33c234ba7cd13965b1236bfee Mon Sep 17 00:00:00 2001 From: Tom Clegg Date: Fri, 7 Aug 2015 22:50:24 -0400 Subject: [PATCH] 6934: Add arvados_pam package. --- sdk/pam/.gitignore | 1 + sdk/pam/MANIFEST.in | 1 + sdk/pam/README.rst | 21 ++++ sdk/pam/arvados_pam.py | 100 ------------------- sdk/pam/arvados_pam/__init__.py | 134 ++++++++++++++++++++++++++ sdk/pam/gittaggers.py | 1 + sdk/pam/setup.py | 39 ++++++++ sdk/pam/tests/__init__.py | 0 sdk/pam/tests/test_pam.py | 164 ++++++++++++++++++++++++++++++++ 9 files changed, 361 insertions(+), 100 deletions(-) create mode 120000 sdk/pam/.gitignore create mode 100644 sdk/pam/MANIFEST.in create mode 100644 sdk/pam/README.rst delete mode 100644 sdk/pam/arvados_pam.py create mode 100644 sdk/pam/arvados_pam/__init__.py create mode 120000 sdk/pam/gittaggers.py create mode 100644 sdk/pam/setup.py create mode 100644 sdk/pam/tests/__init__.py create mode 100644 sdk/pam/tests/test_pam.py diff --git a/sdk/pam/.gitignore b/sdk/pam/.gitignore new file mode 120000 index 0000000000..1399fd4a3d --- /dev/null +++ b/sdk/pam/.gitignore @@ -0,0 +1 @@ +../python/.gitignore \ No newline at end of file diff --git a/sdk/pam/MANIFEST.in b/sdk/pam/MANIFEST.in new file mode 100644 index 0000000000..9561fb1061 --- /dev/null +++ b/sdk/pam/MANIFEST.in @@ -0,0 +1 @@ +include README.rst diff --git a/sdk/pam/README.rst b/sdk/pam/README.rst new file mode 100644 index 0000000000..fdf1f8ebc1 --- /dev/null +++ b/sdk/pam/README.rst @@ -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 index b38e54f046..0000000000 --- a/sdk/pam/arvados_pam.py +++ /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 index 0000000000..4db6e58b12 --- /dev/null +++ b/sdk/pam/arvados_pam/__init__.py @@ -0,0 +1,134 @@ +import sys +sys.argv=[''] + +import arvados +import os +import syslog + +def auth_log(msg): + """Send errors to default auth log""" + syslog.openlog(facility=syslog.LOG_AUTH) + syslog.syslog('arvados_pam: ' + msg) + syslog.closelog() + +def config_file(): + return file('/etc/default/arvados_pam') + +def config(): + txt = config_file().read() + c = dict() + for x in txt.splitlines(False): + if not x.strip().startswith('#'): + kv = x.split('=', 2) + c[kv[0].strip()] = kv[1].strip() + return c + +class AuthEvent(object): + def __init__(self, client_host, api_host, shell_host, username, token): + self.client_host = client_host + self.api_host = api_host + self.shell_hostname = shell_host + self.username = username + self.token = token + self.vm = None + self.user = None + + def can_login(self): + ok = False + try: + self.arv = arvados.api('v1', host=self.api_host, token=self.token, cache=None) + self._lookup_vm() + if self._check_login_permission(): + self.result = 'Authenticated' + ok = True + else: + self.result = 'Denied' + except Exception as e: + self.result = 'Error: ' + repr(e) + auth_log(self.message()) + return ok + + def _lookup_vm(self): + """Load the VM record for this host into self.vm. Raise if not possible.""" + + vms = self.arv.virtual_machines().list(filters=[['hostname','=',self.shell_hostname]]).execute() + if vms['items_available'] > 1: + raise Exception("ambiguous VM hostname matched %d records" % vms['items_available']) + if vms['items_available'] == 0: + raise Exception("VM hostname not found") + self.vm = vms['items'][0] + if self.vm['hostname'] != self.shell_hostname: + raise Exception("API returned record with wrong hostname") + + def _check_login_permission(self): + """Check permission to log in. Return True if permission is granted.""" + self._lookup_vm() + 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 True + return False + + def message(self): + if len(self.token) > 40: + log_token = self.token[0:15] + else: + log_token = '' + log_label = [self.client_host, self.api_host, self.shell_hostname, self.username, log_token] + if self.vm: + log_label += [self.vm.get('uuid')] + if self.user: + log_label += [self.user.get('uuid'), self.user.get('full_name')] + return str(log_label) + ': ' + self.result + + +def pam_sm_authenticate(pamh, flags, argv): + try: + user = pamh.get_user() + except pamh.exception as 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 as e: + return e.pam_result + + try: + config = config() + api_host = config['ARVADOS_API_HOST'].strip() + shell_host = config['HOSTNAME'].strip() + except Exception as e: + auth_log("loading config: " + repr(e)) + return False + + if AuthEvent(pamh.rhost, api_host, shell_host, user, resp.resp).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/gittaggers.py b/sdk/pam/gittaggers.py new file mode 120000 index 0000000000..d59c02ca17 --- /dev/null +++ b/sdk/pam/gittaggers.py @@ -0,0 +1 @@ +../python/gittaggers.py \ No newline at end of file diff --git a/sdk/pam/setup.py b/sdk/pam/setup.py new file mode 100644 index 0000000000..ed47388f2b --- /dev/null +++ b/sdk/pam/setup.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python + +import os +import sys +import setuptools.command.egg_info as egg_info_cmd + +from setuptools import setup, find_packages + +SETUP_DIR = os.path.dirname(__file__) or '.' +README = os.path.join(SETUP_DIR, 'README.rst') + +try: + import gittaggers + tagger = gittaggers.EggInfoFromGit +except ImportError: + tagger = egg_info_cmd.egg_info + +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=[ + ], + install_requires=[ + 'arvados-python-client>=0.1.20150801000000', + ], + test_suite='tests', + tests_require=['mock>=1.0', 'PyYAML'], + 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 index 0000000000..e69de29bb2 diff --git a/sdk/pam/tests/test_pam.py b/sdk/pam/tests/test_pam.py new file mode 100644 index 0000000000..f59a4fd6de --- /dev/null +++ b/sdk/pam/tests/test_pam.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python + +import arvados +import arvados_pam +import mock +import StringIO +import unittest + +class ConfigTest(unittest.TestCase): + def test_ok_config(self): + self.assertConfig( + "#comment\nARVADOS_API_HOST=xyzzy.example\nHOSTNAME=foo.shell\n#HOSTNAME=bogus\n", + 'xyzzy.example', + 'foo.shell') + + def test_config_missing_apihost(self): + with self.assertRaises(KeyError): + self.assertConfig('HOSTNAME=foo', '', 'foo') + + def test_config_missing_shellhost(self): + with self.assertRaises(KeyError): + self.assertConfig('ARVADOS_API_HOST=foo', 'foo', '') + + def test_config_empty_shellhost(self): + self.assertConfig("ARVADOS_API_HOST=foo\nHOSTNAME=\n", 'foo', '') + + def test_config_strip_whitespace(self): + self.assertConfig(" ARVADOS_API_HOST = foo \n\tHOSTNAME\t=\tbar\t\n", 'foo', 'bar') + + @mock.patch('arvados_pam.config_file') + def assertConfig(self, txt, apihost, shellhost, config_file): + configfake = StringIO.StringIO(txt) + config_file.side_effect = [configfake] + c = arvados_pam.config() + self.assertEqual(apihost, c['ARVADOS_API_HOST']) + self.assertEqual(shellhost, c['HOSTNAME']) + +class AuthTest(unittest.TestCase): + + default_request = { + 'api_host': 'zzzzz.api_host.example', + 'shell_host': 'testvm2.shell', + 'token': '3kg6k6lzmp9kj5cpkcoxie963cmvjahbt2fod9zru30k1jqdmi', + 'username': 'active', + } + + default_response = { + 'links': lambda: { + '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': lambda: { + 'uuid': 'zzzzz-tpzed-xurymjxw79nv3jz', + 'full_name': 'Active User', + }, + 'virtual_machines': lambda: { + 'items': [{ + 'uuid': 'zzzzz-2x53u-382brsig8rp3065', + 'hostname': 'testvm2.shell', + }], + 'items_available': 1, + }, + } + + def attempt(self): + return arvados_pam.AuthEvent('::1', **self.request).can_login() + + def test_success(self): + self.assertTrue(self.attempt()) + self.api_client.virtual_machines().list.assert_called_with( + filters=[['hostname','=',self.request['shell_host']]]) + self.api.assert_called_with( + 'v1', host=self.request['api_host'], token=self.request['token'], cache=None) + + def test_fail_vm_lookup(self): + self.response['virtual_machines'] = self._raise + self.assertFalse(self.attempt()) + + def test_vm_hostname_not_found(self): + self.response['virtual_machines'] = lambda: { + 'items': [], + 'items_available': 0, + } + self.assertFalse(self.attempt()) + + def test_vm_hostname_ambiguous(self): + self.response['virtual_machines'] = lambda: { + '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'] = lambda: { + 'items': [ + { + 'uuid': 'zzzzz-2x53u-382brsig8rp3065', + 'hostname': 'testvm22.shell', # <----- + }, + ], + 'items_available': 1, + } + self.assertFalse(self.attempt()) + + def test_fail_user_lookup(self): + self.response['users'] = self._raise + self.assertFalse(self.attempt()) + + def test_fail_permission_check(self): + self.response['links'] = self._raise + self.assertFalse(self.attempt()) + + def test_no_login_permission(self): + self.response['links'] = lambda: { + 'items': [], + } + self.assertFalse(self.attempt()) + + def test_server_ignores_permission_filters(self): + self.response['links'] = lambda: { + '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()) + + def setUp(self): + 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] + + def _raise(self, exception=Exception("Test-induced failure"), *args, **kwargs): + raise exception -- 2.30.2