[ARVADOS] updated: 132bbb69970640e2906bc1285bee9709accb3fc7
git at public.curoverse.com
git at public.curoverse.com
Sun Aug 9 01:05:11 EDT 2015
Summary of changes:
sdk/pam/.dockerignore | 8 ++
sdk/pam/Dockerfile | 15 +++
sdk/pam/arvados_pam/__init__.py | 23 +++--
sdk/pam/debian/arvados_pam | 10 --
sdk/pam/debian/shellinabox | 136 ----------------------------
sdk/pam/examples/etc_pam.d_arvados-pam-test | 3 +
sdk/pam/examples/etc_pam.d_shellinabox | 25 +++++
sdk/pam/examples/pam-auth-update_arvados | 11 +++
sdk/pam/setup.py | 12 ++-
sdk/pam/tests/test_integration.py | 23 +++++
sdk/pam/tests/test_pam.py | 70 +++++---------
11 files changed, 126 insertions(+), 210 deletions(-)
create mode 100644 sdk/pam/.dockerignore
create mode 100644 sdk/pam/Dockerfile
delete mode 100644 sdk/pam/debian/arvados_pam
delete mode 100644 sdk/pam/debian/shellinabox
create mode 100644 sdk/pam/examples/etc_pam.d_arvados-pam-test
create mode 100644 sdk/pam/examples/etc_pam.d_shellinabox
create mode 100644 sdk/pam/examples/pam-auth-update_arvados
mode change 100644 => 100755 sdk/pam/setup.py
create mode 100644 sdk/pam/tests/test_integration.py
via 132bbb69970640e2906bc1285bee9709accb3fc7 (commit)
from 507110dc0aa1329ac4e5aad59c347a49e9f77364 (commit)
Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.
commit 132bbb69970640e2906bc1285bee9709accb3fc7
Author: Tom Clegg <tom at curoverse.com>
Date: Sun Aug 9 01:05:05 2015 -0400
6934: Load config from pam conf instead of yaml. Add docker/integration tests and example configs.
diff --git a/sdk/pam/.dockerignore b/sdk/pam/.dockerignore
new file mode 100644
index 0000000..d6778dc
--- /dev/null
+++ b/sdk/pam/.dockerignore
@@ -0,0 +1,8 @@
+*~
+*.pyc
+.eggs
+*.egg_info
+build
+debian
+dist
+tmp
diff --git a/sdk/pam/Dockerfile b/sdk/pam/Dockerfile
new file mode 100644
index 0000000..7c45607
--- /dev/null
+++ b/sdk/pam/Dockerfile
@@ -0,0 +1,15 @@
+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
+RUN pip install --upgrade setuptools
+RUN pip install python-pam
+ADD . /pam
+WORKDIR /pam
+RUN ./setup.py sdist
+RUN pip install dist/arvados-pam-*.tar.gz
+COPY examples/pam-auth-update_arvados /usr/share/pam-configs/arvados
+RUN DEBIAN_FRONTEND=noninteractive pam-auth-update arvados --remove unix
+CMD rsyslogd & tail -F /var/log/auth.log & bash
diff --git a/sdk/pam/arvados_pam/__init__.py b/sdk/pam/arvados_pam/__init__.py
index c002f3e..920a364 100644
--- a/sdk/pam/arvados_pam/__init__.py
+++ b/sdk/pam/arvados_pam/__init__.py
@@ -4,20 +4,13 @@ sys.argv=['']
import arvados
import os
import syslog
-import yaml
def auth_log(msg):
- """Send errors to default auth log"""
+ """Log an authentication result to syslogd"""
syslog.openlog(facility=syslog.LOG_AUTH)
syslog.syslog('arvados_pam: ' + msg)
syslog.closelog()
-def config_file():
- return file('/etc/default/arvados_pam.conf').read()
-
-def config():
- return yaml.load(config_file())
-
class AuthEvent(object):
def __init__(self, config, service, client_host, username, token):
self.config = config
@@ -34,10 +27,10 @@ class AuthEvent(object):
"""Return truthy IFF credentials should be accepted."""
ok = False
try:
- self.api_host = self.config[self.service]['ARVADOS_API_HOST']
+ self.api_host = self.config['arvados_api_host']
self.arv = arvados.api('v1', host=self.api_host, token=self.token, cache=None)
- vmname = self.config[self.service]['virtual_machine_hostname']
+ 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']))
@@ -98,6 +91,11 @@ class AuthEvent(object):
def pam_sm_authenticate(pamh, flags, argv):
+ config = {}
+ config['arvados_api_host'] = argv[1]
+ config['virtual_machine_hostname'] = argv[2]
+ config['noprompt'] = (len(argv) > 3 and argv[3] == 'noprompt')
+
try:
username = pamh.get_user()
except pamh.exception as e:
@@ -107,11 +105,12 @@ def pam_sm_authenticate(pamh, flags, argv):
return pamh.PAM_USER_UNKNOWN
try:
- token = pamh.conversation(pamh.Message(pamh.PAM_PROMPT_ECHO_OFF, '')).resp
+ prompt = '' if config['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 AuthEvent(config(),
+ if AuthEvent(config,
service=pamh.service,
client_host=pamh.rhost,
username=username,
diff --git a/sdk/pam/debian/arvados_pam b/sdk/pam/debian/arvados_pam
deleted file mode 100644
index eae61d9..0000000
--- a/sdk/pam/debian/arvados_pam
+++ /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
index b983728..0000000
--- a/sdk/pam/debian/shellinabox
+++ /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
- at include common-account
- at include common-session
- at 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
index 0000000..ddff8c7
--- /dev/null
+++ b/sdk/pam/examples/etc_pam.d_arvados-pam-test
@@ -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
index 0000000..355a85f
--- /dev/null
+++ b/sdk/pam/examples/etc_pam.d_shellinabox
@@ -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
+
+ at include common-account
+ at include common-session
+ at include common-password
+
+session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open
diff --git a/sdk/pam/examples/pam-auth-update_arvados b/sdk/pam/examples/pam-auth-update_arvados
new file mode 100644
index 0000000..eb58694
--- /dev/null
+++ b/sdk/pam/examples/pam-auth-update_arvados
@@ -0,0 +1,11 @@
+# 1. Install in /usr/share/pam-configs/arvados
+# 2. Run `pam-auth-update arvados`
+
+Name: Arvados authentication
+Default: yes
+Priority: 256
+Auth-Type: Primary
+Auth:
+ [success=end default=ignore] pam_python.so /usr/local/lib/python2.7/dist-packages/arvados_pam/__init__.py api.example shell.example
+Auth-Initial:
+ [success=end default=ignore] pam_python.so /usr/local/lib/python2.7/dist-packages/arvados_pam/__init__.py api.example shell.example
diff --git a/sdk/pam/setup.py b/sdk/pam/setup.py
old mode 100644
new mode 100755
index 4942e2d..f8a0c62
--- a/sdk/pam/setup.py
+++ b/sdk/pam/setup.py
@@ -3,17 +3,20 @@
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:
- tagger = egg_info_cmd.egg_info
+ if subprocess.check_call(['git', 'log', '-n1']):
+ tagger = gittaggers.EggInfoFromGit
+except (ImportError, OSError):
+ pass
setup(name='arvados-pam',
version='0.1',
@@ -31,10 +34,9 @@ setup(name='arvados-pam',
],
install_requires=[
'arvados-python-client>=0.1.20150801000000',
- 'pyyaml',
],
test_suite='tests',
- tests_require=['mock>=1.0', 'PyYAML'],
+ tests_require=['mock>=1.0', 'python-pam'],
zip_safe=False,
cmdclass={'egg_info': tagger},
)
diff --git a/sdk/pam/tests/test_integration.py b/sdk/pam/tests/test_integration.py
new file mode 100644
index 0000000..2728bcd
--- /dev/null
+++ b/sdk/pam/tests/test_integration.py
@@ -0,0 +1,23 @@
+import os
+if os.path.exists('/etc/pam.d/arvados-pam-test'):
+ import pam
+ import unittest
+
+ 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='arvados-pam-test'))
+
+ def test_deny_service(self):
+ self.assertFalse(self.p.authenticate('active', ACTIVE_TOKEN, service='login'))
+
+ def test_deny_token(self):
+ self.assertFalse(self.p.authenticate('active', 'bogustoken', service='arvados-pam-test'))
+
+ def test_deny_permission(self):
+ self.assertFalse(self.p.authenticate('spectator', SPECTATOR_TOKEN, service='arvados-pam-test'))
diff --git a/sdk/pam/tests/test_pam.py b/sdk/pam/tests/test_pam.py
index 70956c9..c9c0c36 100644
--- a/sdk/pam/tests/test_pam.py
+++ b/sdk/pam/tests/test_pam.py
@@ -1,45 +1,25 @@
-#!/usr/bin/env python
-
import arvados
import arvados_pam
import mock
+import os
import re
import StringIO
import unittest
-class ConfigTest(unittest.TestCase):
- def test_ok_config(self):
- self.assertConfig(
- """servicename:
- ARVADOS_API_HOST: xyzzy.example
- virtual_machine_hostname: foo.shell
- """, 'servicename', 'xyzzy.example', 'foo.shell')
-
- @mock.patch('arvados_pam.config_file')
- def assertConfig(self, txt, svcname, apihost, shellhost, config_file):
- configfake = StringIO.StringIO(txt)
- config_file.side_effect = [configfake]
- c = arvados_pam.config()
- self.assertEqual(apihost, c[svcname]['ARVADOS_API_HOST'])
- self.assertEqual(shellhost, c[svcname]['virtual_machine_hostname'])
+ACTIVE_TOKEN = '3kg6k6lzmp9kj5cpkcoxie963cmvjahbt2fod9zru30k1jqdmi'
class AuthTest(unittest.TestCase):
-
default_config = {
- 'test_service': {
- 'ARVADOS_API_HOST': 'zzzzz.api_host.example',
- 'virtual_machine_hostname': 'testvm2.shell',
- }
+ 'ARVADOS_API_HOST': 'zzzzz.api_host.example',
+ 'virtual_machine_hostname': 'testvm2.shell',
}
-
default_request = {
'client_host': '::1',
- 'token': '3kg6k6lzmp9kj5cpkcoxie963cmvjahbt2fod9zru30k1jqdmi',
+ 'token': ACTIVE_TOKEN,
'username': 'active',
}
-
default_response = {
- 'links': lambda: {
+ 'links': {
'items': [{
'uuid': 'zzzzz-o0j2j-rah2ya1ohx9xaev',
'tail_uuid': 'zzzzz-tpzed-xurymjxw79nv3jz',
@@ -51,11 +31,11 @@ class AuthTest(unittest.TestCase):
},
}],
},
- 'users': lambda: {
+ 'users': {
'uuid': 'zzzzz-tpzed-xurymjxw79nv3jz',
'full_name': 'Active User',
},
- 'virtual_machines': lambda: {
+ 'virtual_machines': {
'items': [{
'uuid': 'zzzzz-2x53u-382brsig8rp3065',
'hostname': 'testvm2.shell',
@@ -70,34 +50,33 @@ class AuthTest(unittest.TestCase):
def test_success(self):
self.assertTrue(self.attempt())
- cfg = self.config['test_service']
self.api_client.virtual_machines().list.assert_called_with(
- filters=[['hostname','=',cfg['virtual_machine_hostname']]])
+ filters=[['hostname','=',self.config['virtual_machine_hostname']]])
self.api.assert_called_with(
- 'v1', host=cfg['ARVADOS_API_HOST'], token=self.request['token'], cache=None)
+ 'v1', host=self.config['ARVADOS_API_HOST'], token=self.request['token'], cache=None)
self.assertEqual(1, len(self.syslogged))
for i in ['test_service',
self.request['username'],
- self.config['test_service']['ARVADOS_API_HOST'],
- self.response['virtual_machines']()['items'][0]['uuid']]:
+ 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.response['virtual_machines'] = self._raise
+ 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'] = lambda: {
+ self.response['virtual_machines'] = {
'items': [],
'items_available': 0,
}
self.assertFalse(self.attempt())
def test_vm_hostname_ambiguous(self):
- self.response['virtual_machines'] = lambda: {
+ self.response['virtual_machines'] = {
'items': [
{
'uuid': 'zzzzz-2x53u-382brsig8rp3065',
@@ -113,7 +92,7 @@ class AuthTest(unittest.TestCase):
self.assertFalse(self.attempt())
def test_server_ignores_vm_filters(self):
- self.response['virtual_machines'] = lambda: {
+ self.response['virtual_machines'] = {
'items': [
{
'uuid': 'zzzzz-2x53u-382brsig8rp3065',
@@ -125,21 +104,21 @@ class AuthTest(unittest.TestCase):
self.assertFalse(self.attempt())
def test_fail_user_lookup(self):
- self.response['users'] = self._raise
+ self.api_client.users().current().execute.side_effect = Exception("Test-induced failure")
self.assertFalse(self.attempt())
def test_fail_permission_check(self):
- self.response['links'] = self._raise
+ 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'] = lambda: {
+ self.response['links'] = {
'items': [],
}
self.assertFalse(self.attempt())
def test_server_ignores_permission_filters(self):
- self.response['links'] = lambda: {
+ self.response['links'] = {
'items': [{
'uuid': 'zzzzz-o0j2j-rah2ya1ohx9xaev',
'tail_uuid': 'zzzzz-tpzed-xurymjxw79nv3jz',
@@ -158,9 +137,9 @@ class AuthTest(unittest.TestCase):
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']()
+ 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)
@@ -171,6 +150,3 @@ class AuthTest(unittest.TestCase):
self.syslog = patcher.start()
self.addCleanup(patcher.stop)
self.syslog.side_effect = lambda s: self.syslogged.append(s)
-
- def _raise(self, exception=Exception("Test-induced failure"), *args, **kwargs):
- raise exception
-----------------------------------------------------------------------
hooks/post-receive
--
More information about the arvados-commits
mailing list