diff --git a/VERSION b/VERSION index 09a3acf..7ceb040 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 \ No newline at end of file +0.6.1 \ No newline at end of file diff --git a/bin/akamai-eaa b/bin/akamai-eaa index bc497a3..73d16c3 100755 --- a/bin/akamai-eaa +++ b/bin/akamai-eaa @@ -256,7 +256,7 @@ if __name__ == "__main__": if hasattr(config, 'directory_id'): directory_id = config.directory_id d = DirectoryAPI(config, directory_id) - d.list_directories() + d.list_directories(stop_event=cli.stop_event) else: d = DirectoryAPI(config, config.directory_id) if config.action == "sync": diff --git a/bin/config.py b/bin/config.py index 5985473..574142f 100644 --- a/bin/config.py +++ b/bin/config.py @@ -1,5 +1,5 @@ """ - Copyright 2022 Akamai Technologies, Inc. All Rights Reserved. + Copyright 2023 Akamai Technologies, Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -62,6 +62,9 @@ def __init__(self, config_values, configuration, flags=None): listgrp_parser = subsub.add_parser("list", help="List all groups existing in an EAA directory") listgrp_parser.add_argument('--groups', '-g', action='store_true', default=True, help="Display users") listgrp_parser.add_argument('--users', '-u', action='store_true', default=False, help="Display groups") + listgrp_parser.add_argument('--json', action='store_true', default=False, help="Output as JSON") + listgrp_parser.add_argument('--tail', '-f', action='store_true', default=False, + help="Keep watching directory list, do not exit until Control+C/SIGTERM") listgrp_parser.add_argument('search_pattern', nargs='?', help="Search pattern") addgrp_parser = subsub.add_parser("addgroup", help="Add Group") diff --git a/cli.json b/cli.json index a86c12b..2f6afac 100755 --- a/cli.json +++ b/cli.json @@ -5,7 +5,7 @@ "commands": [ { "name": "eaa", - "version": "0.6.0", + "version": "0.6.1", "description": "Akamai CLI for Enterprise Application Access (EAA)" } ] diff --git a/libeaa/common.py b/libeaa/common.py index c4736c3..06ad42e 100644 --- a/libeaa/common.py +++ b/libeaa/common.py @@ -17,7 +17,7 @@ """ #: cli-eaa version [PEP 8] -__version__ = '0.6.0' +__version__ = '0.6.1' import sys from threading import Event @@ -216,7 +216,6 @@ def __init__(self, config=None, api=API_Version.Legacy): ) self._session.mount("https://", siem_api_adapter) else: # EAA {OPEN} API - # TODO handle ambiguity when multiple contract ID are in use self._baseurl = 'https://%s/crux/v1/' % edgerc.get(section, 'host') self._session = requests.Session() self._session.auth = EdgeGridAuth.from_edgerc(edgerc, section) @@ -237,10 +236,17 @@ def user_agent(self): return f"{self._config.ua_prefix} cli-eaa/{__version__}" def build_params(self, params=None): + """ + Merge parameters passed as function argument with arguments from the configuration. + + Return a dictionnary Requests can consume as `params` argument + """ final_params = {"ua": self.user_agent()} final_params.update(self.extra_qs) if hasattr(self._config, 'contract_id') and self._config.contract_id: final_params.update({'contractId': self._config.contract_id}) + if hasattr(self._config, 'accountkey') and self._config.accountkey: + final_params.update({'accountSwitchKey': self._config.accountkey}) if isinstance(params, dict): final_params.update(params) return final_params diff --git a/libeaa/directory.py b/libeaa/directory.py index 88ac220..89c649f 100644 --- a/libeaa/directory.py +++ b/libeaa/directory.py @@ -19,12 +19,56 @@ import requests import datetime import time +import json # cli-eaa modules import util from common import cli, BaseAPI, EAAItem +class DirectoryStatus(Enum): + not_configured = 1 + config_incomplete = 2 + agent_not_assigned = 3 + agent_not_reachable = 4 + configured = 5 + not_reachable = 6 + success = 7 + + +class Status(Enum): + not_added = 1 + added = 2 + no_connector = 3 + pending = 4 + not_reachable = 5 + ok = 6 + + +class SyncState(Enum): + Dirty = 1 + ConnectorSync = 2 + ConnectorSyncError = 3 + CloudZoneSync = 4 + CloudZoneSyncErr = 5 + Synchronized = 6 + + +class Service(Enum): + ActiveDirectory = 1 + LDAP = 2 + Okta = 3 + PingOne = 4 + SAML = 5 + Cloud = 6 + OneLogin = 7 + Google = 8 + Akamai = 9 + AkamaiMSP = 10 + LDS = 11 + SCIM = 12 + + class DirectoryAPI(BaseAPI): """ Interact with EAA directory configurations. @@ -34,9 +78,6 @@ class DirectoryAPI(BaseAPI): # LDAP: 10, # ActiveDirectory: 108 - class DirectoryStatus(Enum): - Status3 = 3 - def __init__(self, configuration, directory_moniker=None): super(DirectoryAPI, self).__init__(configuration, BaseAPI.API_Version.OpenAPI) self._directory = None @@ -77,7 +118,37 @@ def list_users(self, search=None): ln=u.get('last_name') )) - def list_directories(self): + def list_directories(self, interval=300, stop_event=None): + """ + List directories configured in the tenant. + + Args: + follow (bool): Never stop until Control+C or SIGTERM is received + interval (float): Interval in seconds between pulling the API, default is 5 minutes (300s) + stop_event (Event): Main program stop event allowing the function + to stop at the earliest possible + """ + while True or (stop_event and not stop_event.is_set()): + try: + start = time.time() + self.list_directories_once() + if self._config.tail: + sleep_time = interval - (time.time() - start) + if sleep_time > 0: + stop_event.wait(sleep_time) + else: + logging.error(f"The EAA Directory API is slow to respond (could be also a proxy in the middle)," + f" holding for {sleep_time} sec.") + stop_event.wait(sleep_time) + else: + break + except Exception as e: + if self._config.tail: + logging.error(f"General exception {e}, since we are in follow mode (--tail), we keep going.") + else: + raise + + def list_directories_once(self): if self._directory_id: if self._config.users: if self._config.search_pattern and not self._config.batch: @@ -92,24 +163,45 @@ def list_directories(self): if resp.status_code != 200: logging.error("Error retrieve directories (%s)" % resp.status_code) resj = resp.json() - # print(resj) - if not self._config.batch: - cli.header("#dir_id,dir_name,status,user_count") + if not self._config.batch and not self._config.json: + cli.header("#dir_id,dir_name,status,user_count,group_count") total_dir = 0 + dt = datetime.datetime.now(tz=datetime.timezone.utc) for total_dir, d in enumerate(resj.get("objects"), start=1): - cli.print("{scheme}{dirid},{name},{status},{user_count}".format( - scheme=EAAItem.Type.Directory.scheme, - dirid=d.get("uuid_url"), - name=d.get("name"), - status=d.get("directory_status"), - user_count=d.get("user_count")) - ) - if total_dir == 0: - cli.footer("No EAA Directory configuration found.") - elif total_dir == 1: - cli.footer("One EAA Directory configuration found.") - else: - cli.footer("%d EAA Directory configurations found." % total_dir) + output = dict() + output["dir_id"] = EAAItem.Type.Directory.scheme + d.get("uuid_url") + output["service"] = Service(d.get("service")).name + output["name"] = d.get("name") + output["datetime"] = dt.isoformat() + output["enabled"] = d.get("status") == 1 + output["connector_count"] = len(d.get("agents")) + output["directory_status"] = Status(d.get("directory_status")).name + output["group_count"] = d.get("user_count") + output["user_count"] = d.get("group_count") + output["last_sync"] = d.get("last_sync") + if d.get("agents"): + output["connectors"] = d.get("agents") + + if self._config.json: + cli.print(json.dumps(output)) + else: + cli.print("{scheme}{dirid},{name},{status},{directory_status},{user_count},{group_count}".format( + scheme=EAAItem.Type.Directory.scheme, + dirid=d.get("uuid_url"), + name=d.get("name"), + status=d.get("status"), + directory_status=Status(d.get("directory_status")).name, + group_count=d.get("user_count"), + user_count=d.get("group_count")) + ) + + if not self._config.json: + if total_dir == 0: + cli.footer("No EAA Directory configuration found.") + elif total_dir == 1: + cli.footer("One EAA Directory configuration found.") + else: + cli.footer("%d EAA Directory configurations found." % total_dir) def delgroup(self, group_id): raise NotImplementedError("Group deletion is not implemented") diff --git a/test/cli-eaa.bats b/test/cli-eaa.bats index e4ace9c..cff617d 100755 --- a/test/cli-eaa.bats +++ b/test/cli-eaa.bats @@ -1,6 +1,8 @@ #!/usr/bin/env bats # see https://bats-core.readthedocs.io/ +# Make sure you set the AKAMAI_EDGERC_SECTION environment +# Before running the script CLI="python3 ${BATS_TEST_DIRNAME}/../bin/akamai-eaa" SCRIPT_DIR="$( cd "$( dirname "$BATS_TEST_FILENAME" )" >/dev/null 2>&1 && pwd )" @@ -84,3 +86,18 @@ teardown() { [ "$?" -eq 0 ] } +@test "Create and patch app with JQ" { + result="$(${BATS_TEST_DIRNAME}/createapp.bash)" + [ "$?" -eq 0 ] +} + + +@test "Directory list (csv)" { + result="$(${CLI} dir list)" + [ "$?" -eq 0 ] +} + +@test "Directory list (JSON)" { + result="$(${CLI} dir list --json|jq .)" + [ "$?" -eq 0 ] +} diff --git a/test/test.py b/test/test.py index 6386273..0575116 100644 --- a/test/test.py +++ b/test/test.py @@ -25,7 +25,7 @@ . ./venv/bin/activate # or any other location/preference pip install nose nose2-html-report -Run the test +Run all tests .. code-block:: bash cd test @@ -334,6 +334,25 @@ def test_list_directories(self): self.assertEqual(cmd.returncode, 0, 'return code must be 0') +class TestDirectory(CliEAATest): + + def test_directory_health_tail(self): + """ + Run directory command to fetch full health statuses in follow mode + We use the RAW format for convenience (easier to read in the output) + """ + cmd = self.cli_run('-d', '-v', 'dir', 'list', '--json', '--tail') + time.sleep(20) # Long enough to collect some data + cmd.send_signal(signal.SIGINT) + stdout, stderr = cmd.communicate(timeout=50.0) + CliEAATest.cli_print("rc: ", cmd.returncode) + for line in stdout.splitlines(): + CliEAATest.cli_print("stdout>", line) + for line in stderr.splitlines(): + CliEAATest.cli_print("stderr>", line) + self.assertGreater(len(stdout), 0, "No directory health output") + + class TestCliEAA(CliEAATest): """ General commands of the CLI like version or help