diff --git a/tests/functional/base.py b/tests/functional/base.py index 4bb0d30..6585fb4 100644 --- a/tests/functional/base.py +++ b/tests/functional/base.py @@ -1,12 +1,15 @@ # Copyright (C) 2017 Custodia Project Contributors - see LICENSE file from __future__ import absolute_import +import grp import os +import pwd import shutil import socket import subprocess import sys import time +from enum import Enum from string import Template import pytest @@ -17,8 +20,105 @@ from custodia.server.config import parse_config +def wait_pid(process, wait): + timeout = time.time() + wait + while time.time() < timeout: + pid, _ = os.waitpid(process.pid, os.WNOHANG) + if pid == process.pid: + return True + time.sleep(0.1) + return False + + +def wait_socket(process, custodia_socket, wait): + timeout = time.time() + wait + while time.time() < timeout: + if process.poll() is not None: + raise AssertionError( + "Premature termination of Custodia server") + try: + s = socket.socket(family=socket.AF_UNIX) + s.connect(custodia_socket) + except OSError: + pass + else: + return True + time.sleep(0.1) + raise OSError('Timeout error') + + +def translate_meta_uid(meta_uid): + current_uid = None + + if meta_uid == "correct_id": + current_uid = pwd.getpwuid(os.geteuid()).pw_uid + + if meta_uid == "incorrect_id": + actual_uid = pwd.getpwuid(os.geteuid()).pw_uid + for uid in [x.pw_uid for x in pwd.getpwall()]: + if uid != actual_uid: + current_uid = uid + break + + if meta_uid == "correct_name": + current_uid = pwd.getpwuid(os.geteuid()).pw_name + + if meta_uid == "incorrect_name": + actual_name = pwd.getpwuid(os.geteuid()).pw_name + for name in [x.pw_name for x in pwd.getpwall()]: + if name != actual_name: + current_uid = name + break + + if meta_uid == "ignore": + current_uid = -1 + + return current_uid + + +def translate_meta_gid(meta_gid): + current_gid = None + + if meta_gid == "correct_id": + current_gid = grp.getgrgid(os.getegid()).gr_gid + + if meta_gid == "incorrect_id": + actual_user = pwd.getpwuid(os.geteuid()).pw_name + actual_gid = grp.getgrgid(os.getegid()).gr_gid + for gid in [g.gr_gid for g in grp.getgrall() if + actual_user not in g.gr_mem]: + if gid != actual_gid: + current_gid = gid + break + + if meta_gid == "correct_name": + current_gid = grp.getgrgid(os.getegid()).gr_name + + if meta_gid == "incorrect_name": + actual_user = pwd.getpwuid(os.geteuid()).pw_name + actual_group = grp.getgrgid(os.getegid()).gr_name + for name in [g.gr_name for g in grp.getgrall() if + actual_user not in g.gr_mem]: + if name != actual_group: + current_gid = name + break + + if meta_gid == "ignore": + current_gid = -1 + + return current_gid + + +class UniqueNumber(object): + unique_number = 0 + + def get_unique_number(self): + UniqueNumber.unique_number += 1 + return UniqueNumber.unique_number + + @pytest.mark.servertest -class CustodiaServerRunner(object): +class CustodiaServerRunner(UniqueNumber): request_headers = {'REMOTE_USER': 'me'} test_dir = 'tests/functional/tmp' custodia_client = None @@ -27,7 +127,6 @@ class CustodiaServerRunner(object): args = None config = None custodia_conf = None - unique_number = 0 @classmethod def setup_class(cls): @@ -39,35 +138,6 @@ def setup_class(cls): def teardown_class(cls): shutil.rmtree(cls.test_dir) - def _wait_pid(self, process, wait): - timeout = time.time() + wait - while time.time() < timeout: - pid, _ = os.waitpid(process.pid, os.WNOHANG) - if pid == process.pid: - return True - time.sleep(0.1) - return False - - def _wait_socket(self, process, wait): - timeout = time.time() + wait - while time.time() < timeout: - if process.poll() is not None: - raise AssertionError( - "Premature termination of Custodia server") - try: - s = socket.socket(family=socket.AF_UNIX) - s.connect(self.env['CUSTODIA_SOCKET']) - except OSError: - pass - else: - return True - time.sleep(0.1) - raise OSError('Timeout error') - - def get_unique_number(self): - CustodiaServerRunner.unique_number = self.unique_number + 1 - return CustodiaServerRunner.unique_number - @pytest.fixture(scope="class") def simple_configuration(self): with open('tests/functional/conf/template_simple.conf') as f: @@ -110,8 +180,8 @@ def custodia_server(self, simple_configuration, request, dev_null): stdout=stdout, stderr=stderr ) - self._wait_pid(self.process, 2) - self._wait_socket(self.process, 5) + wait_pid(self.process, 2) + wait_socket(self.process, self.env['CUSTODIA_SOCKET'], 5) arg = '{}/custodia.sock'.format(CustodiaServerRunner.test_dir) url = 'http+unix://{}'.format(url_escape(arg, '')) @@ -119,10 +189,136 @@ def custodia_server(self, simple_configuration, request, dev_null): def fin(): self.process.terminate() - if not self._wait_pid(self.process, 2): + if not wait_pid(self.process, 2): self.process.kill() - if not self._wait_pid(self.process, 2): + if not wait_pid(self.process, 2): raise AssertionError("Hard kill failed") request.addfinalizer(fin) return self.custodia_client + + +@pytest.mark.servertest +class CustodiaTestEnvironment(UniqueNumber): + test_dir = 'tests/functional/tmp_auth_plugin' + + @classmethod + def setup_class(cls): + if os.path.isdir(cls.test_dir): + shutil.rmtree(cls.test_dir) + os.makedirs(cls.test_dir) + + @classmethod + def teardown_class(cls): + shutil.rmtree(cls.test_dir) + + def reset_environment(self): + if os.path.isdir(self.test_dir): + shutil.rmtree(self.test_dir) + os.makedirs(self.test_dir) + + +class AuthPlugin(Enum): + SimpleCredsAuth = 1 + SimpleHeaderAuth = 2 + SimpleAuthKeys = 3 + SimpleClientCert = 4 + + +class CustodiaServer(object): + def __init__(self, test_dir, conf_params): + self.process = None + self.custodia_client = None + self.test_dir = test_dir + self.custodia_conf = os.path.join(self.test_dir, 'custodia.conf') + self.params = conf_params + + self.out_fd = os.open(os.devnull, os.O_RDWR) + + self._create_configuration() + + self.args = parse_args([self.custodia_conf]) + _, self.config = parse_config(self.args) + self.env = os.environ.copy() + self.env['CUSTODIA_SOCKET'] = self.config['server_socket'] + + def _get_conf_template(self): + if self.params['auth_type'] == AuthPlugin.SimpleCredsAuth: + return 'tests/functional/conf/template_simple_creds_auth.conf' + if self.params['auth_type'] == AuthPlugin.SimpleHeaderAuth: + return 'tests/functional/conf/template_simple_header_auth.conf' + if self.params['auth_type'] == AuthPlugin.SimpleAuthKeys: + return 'tests/functional/conf/template_simple_auth_keys_auth.conf' + if self.params['auth_type'] == AuthPlugin.SimpleClientCert: + return 'tests/functional/conf/template_simple_client_cert.conf' + + def _create_configuration(self): + with open(self._get_conf_template()) as f: + configstr = f.read() + + if self.params['auth_type'] == AuthPlugin.SimpleCredsAuth: + with (open(self.custodia_conf, 'w+')) as conffile: + t = Template(configstr) + conf = t.substitute( + {'TEST_DIR': self.test_dir, + 'UID': translate_meta_uid(self.params['meta_uid']), + 'GID': translate_meta_gid(self.params['meta_gid'])}) + conffile.write(conf) + + if self.params['auth_type'] == AuthPlugin.SimpleHeaderAuth: + with (open(self.custodia_conf, 'w+')) as conffile: + t = Template(configstr) + conf = t.substitute( + {'TEST_DIR': self.test_dir, + 'HEADER': self.params['header_name'], + 'VALUE': self.params['header_value']}) + conffile.write(conf) + + if self.params['auth_type'] == AuthPlugin.SimpleAuthKeys: + with (open(self.custodia_conf, 'w+')) as conffile: + t = Template(configstr) + conf = t.substitute( + {'TEST_DIR': self.test_dir, + 'STORE_NAMESPACE': self.params['store_namespace'], + 'STORE': self.params['store']}) + conffile.write(conf) + + if self.params['auth_type'] == AuthPlugin.SimpleClientCert: + with (open(self.custodia_conf, 'w+')) as conffile: + t = Template(configstr) + conf = t.substitute({'TEST_DIR': self.test_dir}) + conffile.write(conf) + + def __enter__(self): + # Don't write server messages to stdout unless we are in debug mode + # pylint: disable=no-member + if pytest.config.getoption('debug') or \ + pytest.config.getoption('verbose'): + stdout = stderr = None + else: + stdout = stderr = self.out_fd + # pylint: enable=no-member + + self.process = subprocess.Popen( + [sys.executable, '-m', 'custodia.server', self.custodia_conf], + stdout=stdout, stderr=stderr + ) + + wait_pid(self.process, 2) + wait_socket(self.process, self.env['CUSTODIA_SOCKET'], 5) + + arg = '{}/custodia.sock'.format(self.test_dir) + url = 'http+unix://{}'.format(url_escape(arg, '')) + self.custodia_client = CustodiaHTTPClient(url) + + return self.custodia_client + + def __exit__(self, *args): + os.remove(self.custodia_conf) + self.process.terminate() + if not wait_pid(self.process, 2): + self.process.kill() + if not wait_pid(self.process, 2): + raise AssertionError("Hard kill failed") + os.close(self.out_fd) + os.remove(self.env['CUSTODIA_SOCKET']) diff --git a/tests/functional/conf/template_simple_auth_keys_auth.conf b/tests/functional/conf/template_simple_auth_keys_auth.conf new file mode 100644 index 0000000..1161653 --- /dev/null +++ b/tests/functional/conf/template_simple_auth_keys_auth.conf @@ -0,0 +1,32 @@ +[DEFAULT] +logdir = ${TEST_DIR} +libdir = ${TEST_DIR} +rundir = ${TEST_DIR} +socketdir = ${TEST_DIR} + +[global] +server_socket = ${TEST_DIR}/custodia.sock +auditlog = ${TEST_DIR}/custodia.audit.log +server_string = Test_Custodia_Server +debug = false + +[auth:sak] +handler = SimpleAuthKeys +store_namespace = ${STORE_NAMESPACE} +store = ${STORE} + +# Allow requests for all paths under '/' and '/secrets/' +[authz:paths] +handler = SimplePathAuthz +paths = / /secrets/ + +# Store secrets in a sqlite database called custodia.db in the table 'secrets' +[store:simple] +handler = SqliteStore +dburi = ${TEST_DIR}/custodia.db +table = secrets + +# Serve starting from '/' and using the 'simple' store and the 'Root' handler +[/] +handler = Root +store = simple diff --git a/tests/functional/conf/template_simple_client_cert.conf b/tests/functional/conf/template_simple_client_cert.conf new file mode 100644 index 0000000..ae7d81b --- /dev/null +++ b/tests/functional/conf/template_simple_client_cert.conf @@ -0,0 +1,31 @@ +[DEFAULT] +logdir = ${TEST_DIR} +libdir = ${TEST_DIR} +rundir = ${TEST_DIR} +socketdir = ${TEST_DIR} + +[global] +server_socket = ${TEST_DIR}/custodia.sock +auditlog = ${TEST_DIR}/custodia.audit.log +server_string = Test_Custodia_Server +tls_cafile = ../ca/custodia-ca.pem +debug = false + +[auth:client] +handler = SimpleClientCertAuth + +# Allow requests for all paths under '/' and '/secrets/' +[authz:paths] +handler = SimplePathAuthz +paths = / /secrets/ + +# Store secrets in a sqlite database called custodia.db in the table 'secrets' +[store:simple] +handler = SqliteStore +dburi = ${TEST_DIR}/custodia.db +table = secrets + +# Serve starting from '/' and using the 'simple' store and the 'Root' handler +[/] +handler = Root +store = simple diff --git a/tests/functional/conf/template_simple_creds_auth.conf b/tests/functional/conf/template_simple_creds_auth.conf new file mode 100644 index 0000000..c61da67 --- /dev/null +++ b/tests/functional/conf/template_simple_creds_auth.conf @@ -0,0 +1,32 @@ +[DEFAULT] +logdir = ${TEST_DIR} +libdir = ${TEST_DIR} +rundir = ${TEST_DIR} +socketdir = ${TEST_DIR} + +[global] +server_socket = ${TEST_DIR}/custodia.sock +auditlog = ${TEST_DIR}/custodia.audit.log +server_string = Test_Custodia_Server +debug = false + +[auth:creds] +handler = SimpleCredsAuth +uid = ${UID} +gid = ${GID} + +# Allow requests for all paths under '/' and '/secrets/' +[authz:paths] +handler = SimplePathAuthz +paths = / /secrets/ + +# Store secrets in a sqlite database called custodia.db in the table 'secrets' +[store:simple] +handler = SqliteStore +dburi = ${TEST_DIR}/custodia.db +table = secrets + +# Serve starting from '/' and using the 'simple' store and the 'Root' handler +[/] +handler = Root +store = simple diff --git a/tests/functional/conf/template_simple_header_auth.conf b/tests/functional/conf/template_simple_header_auth.conf new file mode 100644 index 0000000..addd5ab --- /dev/null +++ b/tests/functional/conf/template_simple_header_auth.conf @@ -0,0 +1,32 @@ +[DEFAULT] +logdir = ${TEST_DIR} +libdir = ${TEST_DIR} +rundir = ${TEST_DIR} +socketdir = ${TEST_DIR} + +[global] +server_socket = ${TEST_DIR}/custodia.sock +auditlog = ${TEST_DIR}/custodia.audit.log +server_string = Test_Custodia_Server +debug = false + +[auth:header] +handler = SimpleHeaderAuth +header = ${HEADER} +value = ${VALUE} + +# Allow requests for all paths under '/' and '/secrets/' +[authz:paths] +handler = SimplePathAuthz +paths = / /secrets/ + +# Store secrets in a sqlite database called custodia.db in the table 'secrets' +[store:simple] +handler = SqliteStore +dburi = ${TEST_DIR}/custodia.db +table = secrets + +# Serve starting from '/' and using the 'simple' store and the 'Root' handler +[/] +handler = Root +store = simple diff --git a/tests/functional/test_plugin_auth.py b/tests/functional/test_plugin_auth.py new file mode 100644 index 0000000..834314c --- /dev/null +++ b/tests/functional/test_plugin_auth.py @@ -0,0 +1,153 @@ +# Copyright (C) 2017 Custodia Project Contributors - see LICENSE file + +from __future__ import absolute_import + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend + +import pytest + +from .base import AuthPlugin, CustodiaServer, CustodiaTestEnvironment + + +class TestBasicsAuthPlugins(CustodiaTestEnvironment): + @pytest.mark.parametrize("meta_uid,meta_gid,expected_access", [ + ('correct_id', 'correct_id', 'granted'), + ('correct_id', 'incorrect_id', 'granted'), + ('correct_id', 'correct_name', 'granted'), + ('correct_id', 'incorrect_name', 'granted'), + ('correct_id', 'ignore', 'granted'), + ('incorrect_id', 'correct_id', 'granted'), + ('incorrect_id', 'incorrect_id', 'denied'), + ('incorrect_id', 'correct_name', 'granted'), + ('incorrect_id', 'incorrect_name', 'denied'), + ('incorrect_id', 'ignore', 'denied'), + ('correct_name', 'correct_id', 'granted'), + ('correct_name', 'incorrect_id', 'granted'), + ('correct_name', 'correct_name', 'granted'), + ('correct_name', 'incorrect_name', 'granted'), + ('correct_name', 'ignore', 'granted'), + ('incorrect_name', 'correct_id', 'granted'), + ('incorrect_name', 'incorrect_id', 'denied'), + ('incorrect_name', 'correct_name', 'granted'), + ('incorrect_name', 'incorrect_name', 'denied'), + ('incorrect_name', 'ignore', 'denied'), + ('ignore', 'correct_id', 'granted'), + ('ignore', 'incorrect_id', 'denied'), + ('ignore', 'correct_name', 'granted'), + ('ignore', 'incorrect_name', 'denied'), + ('ignore', 'ignore', 'denied'), + ]) + def test_default_answer_simple_creds_auth(self, meta_uid, meta_gid, + expected_access): + + params = {'auth_type': AuthPlugin.SimpleCredsAuth, + 'meta_uid': meta_uid, + 'meta_gid': meta_gid} + + with CustodiaServer(self.test_dir, params) as server: + + container = 'secrets/bucket{}/'.format(self.get_unique_number()) + + resp = server.post(container, headers={}) + if expected_access == 'granted': + assert resp.status_code == 201 + else: + assert resp.status_code == 403 + + # TODO: After https://github.com/latchset/custodia/pull/230 + # this should be extend by comma-separated cases + @pytest.mark.parametrize("conf_n,conf_v,call_n,call_v,expected_access", [ + ('REMOTE_USER', 'me', 'REMOTE_USER', 'me', 'granted'), + ('REMOTE_USER', 'me', 'REMOTE_USER', 'you', 'denied'), + ('REMOTE_AUTH_USER', 'me', 'REMOTE_AUTH_USER', 'me', 'granted'), + ('REMOTE_AUTH_USER', 'me', 'REMOTE_USER', 'me', 'denied'), + ('REMOTE_USER', 'me you he', 'REMOTE_USER', 'me', 'granted'), + ('REMOTE_USER', 'me you he', 'REMOTE_USER', 'you', 'granted'), + ('REMOTE_USER', 'me you he', 'REMOTE_USER', 'he', 'granted'), + ('REMOTE_USER', 'me you he', 'REMOTE_USER', 'she', 'denied'), + ]) + def test_default_answer_simple_header_auth(self, conf_n, conf_v, call_n, + call_v, expected_access): + + params = {'auth_type': AuthPlugin.SimpleHeaderAuth, + 'header_name': conf_n, + 'header_value': conf_v} + + with CustodiaServer(self.test_dir, params) as server: + + container = 'secrets/bucket{}/'.format(self.get_unique_number()) + + resp = server.post(container, headers={call_n: call_v}) + if expected_access == 'granted': + assert resp.status_code == 201 + else: + assert resp.status_code == 403 + + @pytest.mark.parametrize("conf_k,conf_p,call_k,call_p,expected_access", [ + ('qid', 'P@ssw0rd', 'qid', 'P@ssw0rd', 'granted'), + ('qid', 'P@ssw0rd', 'qid_incorrect', 'P@ssw0rd', 'denied'), + ('qid', 'P@ssw0rd', 'qid', 'P@ssw0rd_incrorrect', 'denied'), + ]) + def test_default_answer_simple_auth_keys_auth(self, conf_k, conf_p, call_k, + call_p, expected_access): + + self.reset_environment() + + # For setup AuthKeys plugin we need authenticate via SimpleHeaderAuth + params = {'auth_type': AuthPlugin.SimpleHeaderAuth, + 'header_name': 'REMOTE_USER', + 'header_value': 'me'} + + with CustodiaServer(self.test_dir, params) as server: + # Create container + container = 'secrets/sak/' + resp = server.post(container, headers={'REMOTE_USER': 'me'}) + assert resp.status_code == 201 + + # Save Autheys + key = '{}{}'.format(container, conf_k) + resp = server.put(key, json={"type": "simple", + "value": conf_p}, + headers={'REMOTE_USER': 'me'}) + assert resp.status_code == 201 + + # Testing of AuthKeys plugin + params = {'auth_type': AuthPlugin.SimpleAuthKeys, + 'store_namespace': 'keys/sak', + 'store': 'simple'} + + with CustodiaServer(self.test_dir, params) as server: + container = 'secrets/bucket{}/'.format(self.get_unique_number()) + + resp = server.post(container, + headers={'CUSTODIA_AUTH_ID': call_k, + 'CUSTODIA_AUTH_KEY': call_p}) + if expected_access == 'granted': + assert resp.status_code == 201 + else: + assert resp.status_code == 403 + + def test_default_answer_simple_client_cert_auth(self): + + params = {'auth_type': AuthPlugin.SimpleClientCert} + + expected_access = True + + with open('tests/ca/custodia-ca.pem', 'rb') as pem_file: + pem_data = pem_file.read() + + cert = x509.load_pem_x509_certificate(pem_data, default_backend()) + + with CustodiaServer(self.test_dir, params) as server: + + container = 'secrets/bucket{}/'.format(self.get_unique_number()) + + resp = server.post(container, + headers={ + 'CUSTODIA_CERT_AUTH': str( + cert.public_key())}) + if expected_access == 'granted': + assert resp.status_code == 201 + else: + assert resp.status_code == 403