From dd195b1701b48505ad66ccf5bb577c226aa80e73 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 5 Apr 2023 13:23:34 -0400 Subject: [PATCH] Add v2 endpoint for telephony log (#208) * Add v2 endpoint for telephony log * Fix test by correcting expected output --- .gitignore | 3 + duo_client/admin.py | 132 +++++++++++++++++-------------- duo_client/logs/__init__.py | 6 ++ duo_client/logs/telephony.py | 65 +++++++++++++++ duo_client/util.py | 21 +++++ examples/log_examples.py | 130 ++++++++++++++++++++++++++++++ examples/report_activity.py | 67 ---------------- examples/trust_monitor_events.py | 12 +-- setup.py | 31 ++++---- tests/admin/base.py | 6 +- tests/admin/test_activity.py | 6 +- tests/admin/test_telephony.py | 42 ++++++++++ 12 files changed, 370 insertions(+), 151 deletions(-) create mode 100644 duo_client/logs/__init__.py create mode 100644 duo_client/logs/telephony.py create mode 100644 duo_client/util.py create mode 100644 examples/log_examples.py delete mode 100755 examples/report_activity.py create mode 100644 tests/admin/test_telephony.py diff --git a/.gitignore b/.gitignore index 9de2d1e..9410ae0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ dist .idea env/ py3env/ +.venv +duo_client.egg-info +*.DS_Store diff --git a/duo_client/admin.py b/duo_client/admin.py index f177c75..b539ed7 100644 --- a/duo_client/admin.py +++ b/duo_client/admin.py @@ -174,44 +174,39 @@ import six.moves.urllib from . import client +from .logs.telephony import Telephony import six import warnings import time import base64 -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone -USER_STATUS_ACTIVE = 'active' -USER_STATUS_BYPASS = 'bypass' -USER_STATUS_DISABLED = 'disabled' -USER_STATUS_LOCKED_OUT = 'locked out' +USER_STATUS_ACTIVE = "active" +USER_STATUS_BYPASS = "bypass" +USER_STATUS_DISABLED = "disabled" +USER_STATUS_LOCKED_OUT = "locked out" -TOKEN_HOTP_6 = 'h6' -TOKEN_HOTP_8 = 'h8' -TOKEN_YUBIKEY = 'yk' +TOKEN_HOTP_6 = "h6" +TOKEN_HOTP_8 = "h8" +TOKEN_YUBIKEY = "yk" VALID_AUTHLOG_REQUEST_PARAMS = [ - 'mintime', - 'maxtime', - 'limit', - 'sort', - 'next_offset', - 'event_types', - 'reasons', - 'results', - 'users', - 'applications', - 'groups', - 'factors', - 'api_version' + "mintime", + "maxtime", + "limit", + "sort", + "next_offset", + "event_types", + "reasons", + "results", + "users", + "applications", + "groups", + "factors", + "api_version", ] -VALID_ACTIVITY_REQUEST_PARAMS = [ - 'mintime', - 'maxtime', - 'limit', - 'sort', - 'next_offset' -] +VALID_ACTIVITY_REQUEST_PARAMS = ["mintime", "maxtime", "limit", "sort", "next_offset"] class Admin(client.Client): @@ -598,12 +593,12 @@ def get_activity_logs(self, **kwargs): "value" : } } - }, + } Raises RuntimeError on error. """ params = {} - today = datetime.utcnow() + today = datetime.now(tz=timezone.utc) default_maxtime = int(today.timestamp() * 1000) default_mintime = int((today - timedelta(days=180)).timestamp() * 1000) @@ -622,8 +617,6 @@ def get_activity_logs(self, **kwargs): if 'limit' in params: params['limit'] = str(int(params['limit'])) - - response = self.json_api_call( 'GET', '/admin/v2/logs/activity', @@ -634,41 +627,64 @@ def get_activity_logs(self, **kwargs): row['host'] = self.host return response - def get_telephony_log(self, - mintime=0): + def get_telephony_log(self, mintime=0, api_version=1, **kwargs): """ Returns telephony log events. mintime - Fetch events only >= mintime (to avoid duplicate - records that have already been fetched) - - Returns: + records that have already been fetched) + api_version - The API version of the handler to use. + Currently, the default api version is v1, but the v1 API + will be deprecated in a future version of the Duo Admin API. + Please migrate to the v2 api at your earliest convenience. + For details on the differences between v1 and v2, + please see Duo's Admin API documentation. (Optional) + + v1 Returns: [ - {'timestamp': , - 'eventtype': "telephony", - 'host': , - 'context': , - 'type': , - 'phone': , - 'credits': }, ... + { + 'timestamp': , + 'eventtype': "telephony", + 'host': , + 'context': , + 'type': , + 'phone': , + 'credits': } ] + + v2 Returns: + { + "items": [ + { + 'context': , + 'credits': , + 'phone': , + 'telephony_id': , + 'ts': , + 'txid': , + 'type': , + 'eventtype': , + 'host': + } + ], + "metadata": { + "next_offset": + "total_objects": { + "relation" : + "value" : + } + } + } Raises RuntimeError on error. """ - # Sanity check mintime as unix timestamp, then transform to string - mintime = str(int(mintime)) - params = { - 'mintime': mintime, - } - response = self.json_api_call( - 'GET', - '/admin/v1/logs/telephony', - params, - ) - for row in response: - row['eventtype'] = 'telephony' - row['host'] = self.host - return response + + if api_version not in [1,2]: + raise ValueError("Invalid API Version") + + if api_version == 2: + return Telephony.get_telephony_logs_v2(self.json_api_call, self.host, **kwargs) + return Telephony.get_telephony_logs_v1(self.json_api_call, self.host, mintime=mintime) def get_users_iterator(self): """ diff --git a/duo_client/logs/__init__.py b/duo_client/logs/__init__.py new file mode 100644 index 0000000..1406671 --- /dev/null +++ b/duo_client/logs/__init__.py @@ -0,0 +1,6 @@ +from __future__ import absolute_import +from .telephony import Telephony + +__all__ = [ + 'Telephony' +] diff --git a/duo_client/logs/telephony.py b/duo_client/logs/telephony.py new file mode 100644 index 0000000..54abd3b --- /dev/null +++ b/duo_client/logs/telephony.py @@ -0,0 +1,65 @@ +from typing import Callable +from duo_client.util import ( + get_params_from_kwargs, + get_log_uri, + get_default_request_times, +) + +VALID_TELEPHONY_V2_REQUEST_PARAMS = [ + "filters", + "mintime", + "maxtime", + "limit", + "sort", + "next_offset", + "account_id", +] + +LOG_TYPE = "telephony" + + +class Telephony: + @staticmethod + def get_telephony_logs_v1(json_api_call: Callable, host: str, mintime=0): + # Sanity check mintime as unix timestamp, then transform to string + mintime = f"{int(mintime)}" + params = { + "mintime": mintime, + } + response = json_api_call( + "GET", + get_log_uri(LOG_TYPE, 1), + params, + ) + for row in response: + row["eventtype"] = LOG_TYPE + row["host"] = host + return response + + @staticmethod + def get_telephony_logs_v2(json_api_call: Callable, host: str, **kwargs): + params = {} + default_mintime, default_maxtime = get_default_request_times() + + params = get_params_from_kwargs(VALID_TELEPHONY_V2_REQUEST_PARAMS, **kwargs) + + if "mintime" not in params: + # If mintime is not provided, the script defaults it to 180 days in past + params["mintime"] = default_mintime + params["mintime"] = f"{int(params['mintime'])}" + if "maxtime" not in params: + # if maxtime is not provided, the script defaults it to now + params["maxtime"] = default_maxtime + params["maxtime"] = f"{int(params['maxtime'])}" + if "limit" in params: + params["limit"] = f"{int(params['limit'])}" + + response = json_api_call( + "GET", + get_log_uri(LOG_TYPE, 2), + params, + ) + for row in response["items"]: + row["eventtype"] = LOG_TYPE + row["host"] = host + return response diff --git a/duo_client/util.py b/duo_client/util.py new file mode 100644 index 0000000..72b02b0 --- /dev/null +++ b/duo_client/util.py @@ -0,0 +1,21 @@ +from typing import Dict, Sequence, Tuple +from datetime import datetime, timedelta, timezone + + +def get_params_from_kwargs(valid_params: Sequence[str], **kwargs) -> Dict: + params = {} + for k in kwargs: + if kwargs[k] is not None and k in valid_params: + params[k] = kwargs[k] + return params + + +def get_log_uri(log_type: str, version: int = 1) -> str: + return f"/admin/v{version}/logs/{log_type}" + + +def get_default_request_times() -> Tuple[int, int]: + today = datetime.now(tz=timezone.utc) + mintime = int((today - timedelta(days=180)).timestamp() * 1000) + maxtime = int(today.timestamp() * 1000) - 120 + return mintime, maxtime diff --git a/examples/log_examples.py b/examples/log_examples.py new file mode 100644 index 0000000..d9621b7 --- /dev/null +++ b/examples/log_examples.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +from __future__ import absolute_import, print_function + +import csv +import json +import sys +from datetime import datetime, timedelta, timezone + +from six.moves import input + +import duo_client + +argv_iter = iter(sys.argv[1:]) + + +def get_next_arg(prompt, default=None): + try: + return next(argv_iter) + except StopIteration: + return input(prompt) or default + + +today = datetime.now(tz=timezone.utc) +default_mintime = int((today - timedelta(days=180)).timestamp()) +default_maxtime = int(today.timestamp() * 1000) - 120 + +# Configuration and information about objects to create. +admin_api = duo_client.Admin( + ikey=get_next_arg("Admin API integration key: "), + skey=get_next_arg("Integration secret key: "), + host=get_next_arg("API hostname: "), +) +params = {} + +mintime = get_next_arg("Mintime: ", default_mintime) +if mintime: + params["mintime"] = mintime + +maxtime = get_next_arg("Maxtime: ", default_maxtime) +if maxtime: + params["maxtime"] = maxtime + +limit = get_next_arg("Limit (1000): ") +if limit: + params["limit"] = limit + +next_offset = get_next_arg("Next_offset: ") +if next_offset: + params["next_offset"] = next_offset + +sort = get_next_arg("Sort (ts:desc): ") +if sort: + params["sort"] = sort + +log_type = get_next_arg("Log Type (telephony_v2): ", "telephony_v2") +print(f"Fetching {log_type} logs...") +reporter = csv.writer(sys.stdout) + +print("==============================") +if log_type == "activity": + params["mintime"] = params["mintime"] * 1000 + activity_logs = admin_api.get_activity_logs(**params) + print( + "Next offset from response: ", activity_logs.get("metadata").get("next_offset") + ) + reporter.writerow( + ("activity_id", "ts", "action", "actor_name", "target_name", "application") + ) + for log in activity_logs["items"]: + activity = log["activity_id"] + ts = log["ts"] + action = log["action"] + actor_name = log.get("actor", {}).get("name", None) + target_name = log.get("target", {}).get("name", None) + application = log.get("application", {}).get("name", None) + reporter.writerow( + [ + activity, + ts, + action, + actor_name, + target_name, + application, + ] + ) +if log_type == "telephony_v2": + telephony_logs = admin_api.get_telephony_log(api_version=2, kwargs=params) + reporter.writerow(("telephony_id", "txid", "credits", "context", "phone", "type")) + + for log in telephony_logs["items"]: + telephony_id = log["telephony_id"] + txid = log["txid"] + credits = log["credits"] + context = log["context"] + phone = log["phone"] + type = log["type"] + reporter.writerow( + [ + telephony_id, + txid, + credits, + context, + phone, + type + ] + ) +if log_type == "auth": + auth_logs = admin_api.get_authentication_log(api_version=2, kwargs=params) + print( + "Next offset from response: ", + auth_logs.get("metadata").get("next_offset"), + ) + reporter.writerow(("admin", "akey", "context", "phone", "provider")) + for log in auth_logs["authlogs"]: + admin = log["admin_name"] + akey = log["akey"] + context = log["context"] + phone = log["phone"] + provider = log["provider"] + reporter.writerow( + [ + admin, + akey, + context, + phone, + provider, + ] + ) + +print("==============================") diff --git a/examples/report_activity.py b/examples/report_activity.py deleted file mode 100755 index 03598bb..0000000 --- a/examples/report_activity.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python -from __future__ import absolute_import -from __future__ import print_function -import sys -import csv -import duo_client -from six.moves import input - -argv_iter = iter(sys.argv[1:]) -def get_next_arg(prompt): - try: - return next(argv_iter) - except StopIteration: - return input(prompt) - -# Configuration and information about objects to create. -admin_api = duo_client.Admin( - ikey=get_next_arg('Admin API integration key ("DI..."): '), - skey=get_next_arg('integration secret key: '), - host=get_next_arg('API hostname ("api-....duosecurity.com"): '), -) -kwargs = {} -#get arguments -mintime = get_next_arg('Mintime: ') -if mintime: - kwargs['mintime'] = mintime - -maxtime = get_next_arg('Maxtime: ') -if maxtime: - kwargs['maxtime'] = maxtime - -limit_arg = get_next_arg('Limit (Default = 100, Max = 1000): ') -if limit_arg: - kwargs['limit'] = limit_arg - -next_offset_arg = get_next_arg('Next_offset: ') -if next_offset_arg: - kwargs['next_offset'] = next_offset_arg - -sort_arg = get_next_arg('Sort (Default - ts:desc) :') -if sort_arg: - kwargs['sort'] = sort_arg - -reporter = csv.writer(sys.stdout) - -logs = admin_api.get_activity_logs(**kwargs) - -print("==============================") -print("Next offset from response : ", logs.get('metadata').get('next_offset')) - -reporter.writerow(('activity_id', 'ts', 'action', 'actor_name', 'target_name')) - -for log in logs['items']: - activity = log['activity_id'], - ts = log['ts'] - action = log['action'] - actor_name = log.get('actor', {}).get('name', None) - target_name = log.get('target', {}).get('name', None) - reporter.writerow([ - activity, - ts, - action, - actor_name, - target_name, - ]) - -print("==============================") \ No newline at end of file diff --git a/examples/trust_monitor_events.py b/examples/trust_monitor_events.py index daf0e5f..12a8662 100755 --- a/examples/trust_monitor_events.py +++ b/examples/trust_monitor_events.py @@ -1,15 +1,15 @@ #!/usr/bin/env python """Print Duo Trust Monitor Events which surfaced within the past two weeks.""" -from __future__ import print_function -from __future__ import absolute_import -import datetime +from __future__ import absolute_import, print_function + import json import sys +from datetime import datetime, timedelta, timezone -from duo_client import Admin from six.moves import input +from duo_client import Admin argv_iter = iter(sys.argv[1:]) def get_next_arg(prompt): @@ -24,8 +24,8 @@ def main(args): admin_client = Admin(args[0], args[1], args[2]) # Query for Duo Trust Monitor events that were surfaced within the last two weeks (from today). - now = datetime.datetime.utcnow() - mintime_ms = int((now - datetime.timedelta(weeks=2)).timestamp() * 1000) + now = datetime.now(tz=timezone.utc) + mintime_ms = int((now - timedelta(weeks=2)).timestamp() * 1000) maxtime_ms = int(now.timestamp() * 1000) # Loop over the returned iterator to navigate through each event, printing it to stdout. diff --git a/setup.py b/setup.py index 0b1e4a6..236dcf8 100644 --- a/setup.py +++ b/setup.py @@ -6,38 +6,41 @@ import duo_client requirements_filename = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'requirements.txt') + os.path.dirname(os.path.abspath(__file__)), "requirements.txt" +) with open(requirements_filename) as fd: install_requires = [i.strip() for i in fd.readlines()] requirements_dev_filename = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'requirements-dev.txt') + os.path.dirname(os.path.abspath(__file__)), "requirements-dev.txt" +) with open(requirements_dev_filename) as fd: tests_require = [i.strip() for i in fd.readlines()] long_description_filename = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'README.md') + os.path.dirname(os.path.abspath(__file__)), "README.md" +) with open(long_description_filename) as fd: long_description = fd.read() setup( - name='duo_client', + name="duo_client", version=duo_client.__version__, - description='Reference client for Duo Security APIs', + description="Reference client for Duo Security APIs", long_description=long_description, - long_description_content_type='text/markdown', - author='Duo Security, Inc.', - author_email='support@duosecurity.com', - url='https://github.com/duosecurity/duo_client_python', - packages=['duo_client'], - package_data={'duo_client': ['ca_certs.pem']}, - license='BSD', + long_description_content_type="text/markdown", + author="Duo Security, Inc.", + author_email="support@duosecurity.com", + url="https://github.com/duosecurity/duo_client_python", + packages=["duo_client", "duo_client.logs"], + package_data={"duo_client": ["ca_certs.pem"]}, + license="BSD", classifiers=[ - 'Programming Language :: Python', - 'License :: OSI Approved :: BSD License', + "Programming Language :: Python", + "License :: OSI Approved :: BSD License", ], install_requires=install_requires, tests_require=tests_require, diff --git a/tests/admin/base.py b/tests/admin/base.py index edd53e5..a2bdc37 100644 --- a/tests/admin/base.py +++ b/tests/admin/base.py @@ -43,10 +43,10 @@ def setUp(self): self.client_dtm._connect = \ lambda: util.MockHTTPConnection(data_response_from_get_dtm_events=True) - self.client_activity = duo_client.admin.Admin( + self.items_response_client = duo_client.admin.Admin( 'test_ikey', 'test_akey', 'example.com') - self.client_activity.account_id = 'DA012345678901234567' - self.client_activity._connect = \ + self.items_response_client.account_id = 'DA012345678901234567' + self.items_response_client._connect = \ lambda: util.MockHTTPConnection(data_response_from_get_items=True) diff --git a/tests/admin/test_activity.py b/tests/admin/test_activity.py index a6bf3e0..1ca785c 100644 --- a/tests/admin/test_activity.py +++ b/tests/admin/test_activity.py @@ -10,21 +10,21 @@ class TestEndpoints(TestAdmin): def test_get_activity_log(self): """ Test to get activities log. """ - response = self.client_activity.get_activity_logs(maxtime='1663131599000', mintime='1662958799000') + response = self.items_response_client.get_activity_logs(maxtime='1663131599000', mintime='1662958799000') uri, args = response['uri'].split('?') self.assertEqual(response['method'], 'GET') self.assertEqual(uri, '/admin/v2/logs/activity') self.assertEqual( util.params_to_dict(args)['account_id'], - [self.client_activity.account_id]) + [self.items_response_client.account_id]) @freeze_time('2022-10-01') def test_get_activity_log_with_no_args(self): freezed_time = datetime(2022,10,1,0,0,0, tzinfo=pytz.utc) expected_mintime = str(int((freezed_time-timedelta(days=180)).timestamp()*1000)) expected_maxtime = str(int(freezed_time.timestamp() * 1000)) - response = self.client_activity.get_activity_logs() + response = self.items_response_client.get_activity_logs() uri, args = response['uri'].split('?') param_dict = util.params_to_dict(args) self.assertEqual(response['method'], 'GET') diff --git a/tests/admin/test_telephony.py b/tests/admin/test_telephony.py new file mode 100644 index 0000000..20a6d0f --- /dev/null +++ b/tests/admin/test_telephony.py @@ -0,0 +1,42 @@ +from .. import util +from .base import TestAdmin +from datetime import datetime, timedelta +from freezegun import freeze_time +import pytz + + +class TestTelephonyLogEndpoints(TestAdmin): + def test_get_telephony_logs_v2(self): + """Test to get activities log.""" + response = self.items_response_client.get_telephony_log( + maxtime=1663131599000, mintime=1662958799000, api_version=2 + ) + uri, args = response["uri"].split("?") + + self.assertEqual(response["method"], "GET") + self.assertEqual(uri, "/admin/v2/logs/telephony") + self.assertEqual( + util.params_to_dict(args)["account_id"], [self.items_response_client.account_id] + ) + + @freeze_time("2022-10-01") + def test_get_telephony_logs_v2_no_args(self): + freezed_time = datetime(2022, 10, 1, 0, 0, 0, tzinfo=pytz.utc) + expected_mintime = str( + int((freezed_time - timedelta(days=180)).timestamp() * 1000) + ) + expected_maxtime = str(int(freezed_time.timestamp() * 1000) - 120) + response = self.items_response_client.get_telephony_log(api_version=2) + uri, args = response["uri"].split("?") + param_dict = util.params_to_dict(args) + self.assertEqual(response["method"], "GET") + self.assertEqual(uri, "/admin/v2/logs/telephony") + self.assertEqual(param_dict["mintime"], [expected_mintime]) + self.assertEqual(param_dict["maxtime"], [expected_maxtime]) + + @freeze_time("2022-10-01") + def test_get_telephony_logs_v1_no_args(self): + response = self.client_list.get_telephony_log() + uri, args = response[0]["uri"].split("?") + self.assertEqual(response[0]["method"], "GET") + self.assertEqual(uri, "/admin/v1/logs/telephony")