diff --git a/duo_client/__init__.py b/duo_client/__init__.py index 16b1a25..0361bce 100644 --- a/duo_client/__init__.py +++ b/duo_client/__init__.py @@ -2,10 +2,12 @@ from .accounts import Accounts from .admin import Admin from .auth import Auth +from .device import Device from .client import __version__ __all__ = [ 'Accounts', 'Admin', 'Auth', + 'Device', ] diff --git a/duo_client/client.py b/duo_client/client.py index f94fedd..1a63712 100644 --- a/duo_client/client.py +++ b/duo_client/client.py @@ -156,6 +156,7 @@ def to_list(value): class Client(object): def __init__(self, ikey, skey, host, + mkey=None, ca_certs=DEFAULT_CA_CERTS, sig_timezone='UTC', user_agent=('Duo API Python/' + __version__), @@ -170,6 +171,7 @@ def __init__(self, ikey, skey, host, """ self.ikey = ikey self.skey = skey + self.mkey = mkey self.host = host self.port = port self.sig_timezone = sig_timezone @@ -426,7 +428,7 @@ def json_cursor_api_call(self, method, path, params, get_records_func): :param get_records_func: Function that can be called to extract an iterable of records from the parsed response json. - + :returns: Generator which will yield records from the api response(s). """ diff --git a/duo_client/device.py b/duo_client/device.py new file mode 100644 index 0000000..9ea4bac --- /dev/null +++ b/duo_client/device.py @@ -0,0 +1,138 @@ +""" +Duo Security Device API reference client implementation. + +""" +from __future__ import absolute_import + +import six.moves.urllib + +from . import client +import six +import warnings +import json + +MAX_DEVICE_IDS = 1000 +DEVICE_CACHE_STATUS = [ + "active", + "pending" +] + + +class Device(client.Client): + account_id = None + + def api_call(self, method, path, params): + if self.account_id is not None: + params['account_id'] = self.account_id + return super(Device, self).api_call(method, path, params) + + def _validate_device_ids(self, device_ids): + # For some reason this is a list of dicts + if not isinstance(device_ids, list): + raise ValueError + for device_id_dict in device_ids: + if not isinstance(device_id_dict, dict): + raise ValueError + if 'device_id' not in device_id_dict: + raise ValueError + return True + + def create_device_cache(self): + existing_caches = self.get_device_caches() + for cache in existing_caches: + cache_key = cache['cache_key'] + if cache['status'] == "pending": + raise ValueError( + f"Cannot create a cache when a cache is pending ({cache_key})") + return self.json_api_call( + 'POST', + f'/device/v1/management_systems/{self.mkey}/device_cache', + {} + ) + + def delete_device_cache(self, cache_key): + device_cache = self.get_device_cache_by_key(cache_key) + if device_cache['status'] != "pending": + raise ValueError( + f"Only Pending device caches can be deleted ({cache_key})") + return self.json_api_call( + 'DELETE', + f'/device/v1/management_systems/{self.mkey}/device_cache/{cache_key}', + {} + ) + + def activate_device_cache(self, cache_key): + return self.json_api_call( + 'POST', + f'/device/v1/management_systems/{self.mkey}/device_cache/{cache_key}/activate', + {} + ) + + def add_device_to_cache(self, cache_key, device_ids): + # We only return the last result + result = None + for device_start in range(0, len(device_ids), MAX_DEVICE_IDS): + device_end = device_start + MAX_DEVICE_IDS + if device_end > len(device_ids): + device_end = len(device_ids) + device_ids_chunk = device_ids[device_start:device_end] + self._validate_device_ids(device_ids_chunk) + params = { + 'devices': json.dumps(device_ids_chunk), + } + + result = self.json_api_call( + 'POST', + f'/device/v1/management_systems/{self.mkey}/device_cache/{cache_key}/devices', + params + ) + return result + + def get_device_caches(self, status=None): + params = {} + if status: + if status not in DEVICE_CACHE_STATUS: + raise ValueError + params = {"status": status} + params = six.moves.urllib.parse.urlencode(params, doseq=True) + if params: + url = f'/device/v1/management_systems/{self.mkey}/device_cache?{params}' + else: + url = f'/device/v1/management_systems/{self.mkey}/device_cache' + return self.json_api_call( + 'GET', + url, + {} + ) + + def get_device_cache_by_key(self, cache_key): + return self.json_api_call( + 'GET', + f'/device/v1/management_systems/{self.mkey}/device_cache/{cache_key}', + {} + ) + + def get_device_cache_by_key(self, cache_key): + return self.json_api_call( + 'GET', + f'/device/v1/management_systems/{self.mkey}/device_cache/{cache_key}', + {} + ) + + def activate_cache_with_devices(self, device_ids, cache_key=None): + # Remove any existing pending caches, since there can only be one + if not cache_key: + existing_caches = self.get_device_caches() + for cache in existing_caches: + if cache['status'] == 'pending': + cache_key = cache['cache_key'] + self.delete_device_cache(cache_key) + device_cache = self.create_device_cache() + else: + device_cache = self.get_device_cache_by_key(cache_key) + if device_cache['status'] != 'pending': + raise ValueError("Specified Cache is not in pending state") + cache_key = device_cache['cache_key'] + self.add_device_to_cache(cache_key, device_ids) + self.activate_device_cache(cache_key) + return self.get_device_cache_by_key(cache_key) diff --git a/examples/add_generic_devices_from_csv.py b/examples/add_generic_devices_from_csv.py new file mode 100644 index 0000000..d4e1ff0 --- /dev/null +++ b/examples/add_generic_devices_from_csv.py @@ -0,0 +1,68 @@ +#!/usr/bin/python +import duo_client +import argparse +import csv + + +def arg_parser(): + parser = argparse.ArgumentParser() + parser.add_argument( + '--infile', + required=True, + help='The path to the input CSV file.' + ) + parser.add_argument( + '--device_id_column', + default='device_id', + help='The name of the column in the csv file that has the device ID', + ) + parser.add_argument( + '--mkey', + required=len(MKEY_CREDENTIALS.keys())>1, + help='The Duo Device API managment key: ', + choices=MKEY_CREDENTIALS.keys() + ) + parser.add_argument( + '--ikey', + required=len(MKEY_CREDENTIALS.keys())>1, + help='The Duo Device API integration key: ', + choices=MKEY_CREDENTIALS.keys() + ) + parser.add_argument( + '--skey', + required=len(MKEY_CREDENTIALS.keys())>1, + help='The Duo Device API secret key: ', + choices=MKEY_CREDENTIALS.keys() + ) + parser.add_argument( + '--host', + required=len(MKEY_CREDENTIALS.keys())>1, + help='The Duo Device API hostname ("api-....duosecurity.com"): ', + choices=MKEY_CREDENTIALS.keys() + ) + return parser + + +def upload_identifiers(device_api, csvfile, device_id_column): + device_ids = [] + with open(args.infile) as csvfile: + reader = csv.DictReader(csvfile, skipinitialspace=True) + for row in reader: + if row[device_id_column]: + device_ids.append({'device_id': row[device_id_column]}) + if not len(device_ids): + raise ValueError( + f'No device IDs read from input column: {device_id_column}') + self.device_api.activate_cache_with_devices(device_ids) + + +def main(): + parser = arg_parser() + args = parser.parse_args() + device_api = duo_client.client.Client( + ikey=args.ikey, + skey=args.skey, + host=args.host, + mkey=args.mkey + ) + upload_identifiers(device_api, args.infile, args.device_id_column) diff --git a/tests/device/__init__.py b/tests/device/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/device/test_generic_integration.py b/tests/device/test_generic_integration.py new file mode 100644 index 0000000..78caf23 --- /dev/null +++ b/tests/device/test_generic_integration.py @@ -0,0 +1,209 @@ +import import duo_client.device +from .. import util +import uuid +import pytest + + +class TestDeviceGeneric(unittest.TestCase): + def setUp(self): + self.device_api = duo_client.device.Device( + 'test_ikey', 'test_akey', 'example.com', mkey='test_mkey') + ) + # monkeypatch client's _connect() + self.client._connect = lambda: util.MockHTTPConnection() + + def test_create_pending(): + existing_caches = self.device_api.get_device_caches() + for cache in existing_caches: + if cache['status'] == 'pending': + cache_key = cache['cache_key'] + self.device_api.delete_device_cache(cache_key) + # Test creating and activating two caches + device_cache = self.device_api.create_device_cache() + assert isinstance(device_cache, dict) + assert 'cache_key' in device_cache + cache_key = device_cache['cache_key'] + cache_key2 = None + with pytest.raises(ValueError) as err: + device_cache2 = self.device_api.create_device_cache() + cache_key2 = device_cache2['cache_key'] + self.device_api.delete_device_cache(cache_key) + self.device_api.delete_device_cache(cache_key2) + self.device_api.delete_device_cache(cache_key) + assert err + + def test_create_and_delete_pending(): + existing_caches = self.device_api.get_device_caches() + for cache in existing_caches: + if cache['status'] == 'pending': + cache_key = cache['cache_key'] + self.device_api.delete_device_cache(cache_key) + # Test creating and activating two caches + device_cache = self.device_api.create_device_cache() + cache_key = device_cache['cache_key'] + resp = self.device_api.delete_device_cache(cache_key) + assert isinstance(resp, dict) + assert resp == {} + + def test_create_and_fetch(): + existing_caches = self.device_api.get_device_caches() + for cache in existing_caches: + if cache['status'] == 'pending': + cache_key = cache['cache_key'] + self.device_api.delete_device_cache(cache_key) + # Test creating and activating two caches + device_cache = self.device_api.create_device_cache() + cache_key = device_cache['cache_key'] + caches = self.device_api.get_device_caches() + assert [cache['cache_key'] for cache in caches] + self.device_api.delete_device_cache(cache_key) + + def test_add_devices_to_cache(): + existing_caches = self.device_api.get_device_caches() + for cache in existing_caches: + if cache['status'] == 'pending': + cache_key = cache['cache_key'] + self.device_api.delete_device_cache(cache_key) + # Test creating and activating two caches + device_cache = self.device_api.create_device_cache() + device_ids = [{"device_id": str(uuid.uuid4())}] + cache_key = device_cache['cache_key'] + # Add a single device + self.device_api.add_device_to_cache(cache_key, device_ids) + cache = self.device_api.get_device_cache_by_key(cache_key) + assert cache['device_count'] == 1 + # Readd the same device once + self.device_api.add_device_to_cache(cache_key, device_ids) + cache = self.device_api.get_device_cache_by_key(cache_key) + assert cache['device_count'] == 1 + # Readd the same device 5 times + for _ in range(5): + self.device_api.add_device_to_cache(cache_key, device_ids) + cache = self.device_api.get_device_cache_by_key(cache_key) + assert cache['device_count'] == 1 + # Add two additional devices to the one existing device + device_ids = [{"device_id": str(uuid.uuid4())}, {"device_id": str(uuid.uuid4())}] + self.device_api.add_device_to_cache(cache_key, device_ids) + cache = self.device_api.get_device_cache_by_key(cache_key) + assert cache['device_count'] == 3 + # Add 999 devices (below max per request) + device_ids = [] + for _ in range(999): + device_ids.append({"device_id": str(uuid.uuid4())}) + self.device_api.add_device_to_cache(cache_key, device_ids) + cache = self.device_api.get_device_cache_by_key(cache_key) + assert cache['device_count'] == 1002 + # Add 1010 more devices (over max per request) + device_ids = [] + for _ in range(1010): + device_ids.append({"device_id": str(uuid.uuid4())}) + self.device_api.add_device_to_cache(cache_key, device_ids) + cache = self.device_api.get_device_cache_by_key(cache_key) + assert cache['device_count'] == 2012 + self.device_api.delete_device_cache(cache_key) + + + def test_add_invalid_device_to_cache(): + existing_caches = self.device_api.get_device_caches() + for cache in existing_caches: + if cache['status'] == 'pending': + cache_key = cache['cache_key'] + self.device_api.delete_device_cache(cache_key) + # Test creating and activating two caches + device_cache = self.device_api.create_device_cache() + device_ids = [{"device_id": "potato"}] + cache_key = device_cache['cache_key'] + # Add a single device + with pytest.raises(RuntimeError) as err: + self.device_api.add_device_to_cache(cache_key, device_ids) + assert err + cache = self.device_api.get_device_cache_by_key(cache_key) + assert cache['device_count'] == 0 + self.device_api.delete_device_cache(cache_key) + + def test_activate_cache(): + existing_caches = self.device_api.get_device_caches() + for cache in existing_caches: + if cache['status'] == 'pending': + cache_key = cache['cache_key'] + self.device_api.delete_device_cache(cache_key) + # Check if we already have an activated cache + # for unknown reasons there is no way to deactivate a cache + # it must be replaced + found_active_cache = False + for cache in existing_caches: + if cache['status'] == 'active': + found_active_cache = False#cache['cache_key'] + if found_active_cache: + device_cache = self.device_api.create_device_cache() + cache_key = device_cache['cache_key'] + res = self.device_api.activate_device_cache(cache_key) + assert res == {} + existing_caches = self.device_api.get_device_caches() + assert found_active_cache not in [cache['cache_key'] for cache in existing_caches] + assert cache_key in [cache['cache_key'] for cache in existing_caches] + current_cache = self.device_api.get_device_cache_by_key(cache_key) + assert current_cache['status'] == 'active' + if not found_active_cache: + device_cache1 = self.device_api.create_device_cache() + cache_key = device_cache1['cache_key'] + res = self.device_api.activate_device_cache(cache_key) + assert res == {} + current_cache = self.device_api.get_device_cache_by_key(cache_key) + assert current_cache['status'] == 'active' + assert current_cache['cache_key'] == cache_key + device_cache2 = self.device_api.create_device_cache() + cache_key = device_cache2['cache_key'] + res = self.device_api.activate_device_cache(cache_key) + assert res == {} + # Duo silently deletes the active cache on promotion of new cache + existing_caches = self.device_api.get_device_caches() + assert len(existing_caches) == 1 + assert existing_caches[0]['status'] == 'active' + assert existing_caches[0]['cache_key'] == cache_key + + def test_create_and_fetch_specific(): + existing_caches = self.device_api.get_device_caches() + for cache in existing_caches: + if cache['status'] == 'pending': + cache_key = cache['cache_key'] + self.device_api.delete_device_cache(cache_key) + # Test creating and activating two caches + device_cache = self.device_api.create_device_cache() + cache_key = device_cache['cache_key'] + current_cache = self.device_api.get_device_cache_by_key(cache_key) + assert current_cache['cache_key'] == cache_key + assert current_cache['status'] == 'pending' + self.device_api.delete_device_cache(cache_key) + + def test_activate_cache_with_devices_no_predefined(): + # Create 300 random devices for our cache + device_ids = [] + num_devices = 3000 + for _ in range(num_devices): + device_ids.append({"device_id": str(uuid.uuid4())}) + result_cache = self.device_api.activate_cache_with_devices(device_ids) + assert result_cache['device_count'] == num_devices + assert result_cache['status'] == 'active' + + def test_activate_cache_with_devices_predefined(): + existing_caches = self.device_api.get_device_caches() + for cache in existing_caches: + if cache['status'] == 'pending': + cache_key = cache['cache_key'] + self.device_api.delete_device_cache(cache_key) + device_cache = self.device_api.create_device_cache() + cache_key = device_cache['cache_key'] + # Create 300 random devices for our cache + device_ids = [] + num_devices = 3000 + for _ in range(num_devices): + device_ids.append({"device_id": str(uuid.uuid4())}) + result_cache = self.device_api.activate_cache_with_devices(device_ids, cache_key=cache_key) + assert result_cache['device_count'] == num_devices + assert result_cache['status'] == 'active' + + # Test adding devices to an already activated cache + with pytest.raises(ValueError) as err: + self.device_api.activate_cache_with_devices(device_ids, cache_key=cache_key) + assert err