From c73c677e9e69b006b9e915410854f9830e717f52 Mon Sep 17 00:00:00 2001 From: Howard Hellyer Date: Thu, 14 May 2026 13:46:48 +0100 Subject: [PATCH 1/6] PENG-7932 - Python SDK Updates for API Key expiry and renewal. - Add support for creating expiring API Keys. - Add support for managing and renewing expiring API Key secrets. - Add example code for API Key renewal. - Add testcases. --- CHANGELOG.md | 5 + examples/apikey-secret-renewal.py | 69 ++++++++++++++ ns1/__init__.py | 12 ++- ns1/rest/apikey.py | 3 +- ns1/rest/apikey_secret.py | 91 ++++++++++++++++++ tests/unit/test_apikey.py | 12 +++ tests/unit/test_apikey_secrets.py | 147 ++++++++++++++++++++++++++++++ 7 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 examples/apikey-secret-renewal.py create mode 100644 ns1/rest/apikey_secret.py create mode 100644 tests/unit/test_apikey_secrets.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aee5b6c..a524a69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.28.0 (May Nth, 2026) + +ENHANCEMENTS: +* Add support for API Key expiry and secret renewal + ## 0.27.4 (May 14th, 2026) BUG FIXES: diff --git a/examples/apikey-secret-renewal.py b/examples/apikey-secret-renewal.py new file mode 100644 index 0000000..5f5c0b2 --- /dev/null +++ b/examples/apikey-secret-renewal.py @@ -0,0 +1,69 @@ +# +# Copyright (c) 2026 NSONE, Inc. +# +# License under The MIT License (MIT). See LICENSE in project root. +# + +from ns1 import NS1 + +def print_secret(secret): + print(f" Secret ID: {secret['secret_id']}") + print(f" Secret Value: {secret['secret']}") + print(f" Expires At: {secret['expires_at']}") + print(f" Enabled: {secret['enabled']}") + +# NS1 will use config in ~/.nsone by default +api = NS1() + +# to specify an apikey here instead, use: +# api = NS1(apiKey='<>') + +# to load an alternate configuration file: +# api = NS1(configFile='/etc/ns1/api.json') + +######################## +# CREATE API KEY # +######################## + +# Get the API key interface +apikey_api = api.apikey() + +# Create a new API key with a name and expiry_duration +# You can also specify teams, ip_whitelist, ip_whitelist_strict, and permissions +# If permissions are not specified, default permissions (all false) will be used +# expiry_duration is set to 30 days +apikey_id = '' +try: + print('Creating API key with 30 day expiry...') + apikey = apikey_api.create("example-api-key-with-expiry", expiry_duration="30d") + apikey_id = apikey["id"] + print(f"Created API key: {apikey_id}") + + # Store the key ID for later operations + + # Store the actual apikey for later operations + apikey_id = apikey["id"] + apikey_secret = apikey["secrets"][0] + apikey_secret_id= apikey_secret["secret_id"] + apikey_secret_key = apikey_secret["secret"] + + print("Initial api key secret:") + print_secret(apikey_secret) + + ######################## + # RENEW API KEY # + ######################## + + # Use self renewal to renew this secret by creating a new api + # instance that uses the new secret for authentication. + api_with_secret_auth = NS1(apiKey=apikey_secret_key) + apikeysecrets_with_secret_auth = api_with_secret_auth.apikeysecrets() + # The default secret id for renew() is "self" and this will + # renew the secret being used for authentication. + new_secret = apikeysecrets_with_secret_auth.renew() + print(f"Renewed api key secret:") + print_secret(new_secret) +finally: + # Clean up the API key so this script can be re-run + if apikey_id != '': + apikey_api.delete(apikey_id) diff --git a/ns1/__init__.py b/ns1/__init__.py index 21703d5..9f382bc 100644 --- a/ns1/__init__.py +++ b/ns1/__init__.py @@ -5,7 +5,7 @@ # from .config import Config -version = "0.27.4" +version = "0.28.0" class NS1: @@ -183,6 +183,16 @@ def apikey(self): return ns1.rest.apikey.APIKey(self.config) + def apikeysecrets(self): + """ + Return a new raw REST interface to API key secret resources + + :rtype: :py:class:`ns1.rest.apikey_secret.APIKeySecret` + """ + import ns1.rest.apikey_secret + + return ns1.rest.apikey_secret.APIKeySecret(self.config) + def acls(self): """ Return a new raw REST interface to ACL resources diff --git a/ns1/rest/apikey.py b/ns1/rest/apikey.py index 2ad91e3..75a6c88 100644 --- a/ns1/rest/apikey.py +++ b/ns1/rest/apikey.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2014 NSONE, Inc. +# Copyright (c) 2014, 2026 NSONE, Inc. # # License under The MIT License (MIT). See LICENSE in project root. # @@ -15,6 +15,7 @@ class APIKey(resource.BaseResource): "ip_whitelist", "ip_whitelist_strict", "permissions", + "expiry_duration", ] def create(self, name, callback=None, errback=None, **kwargs): diff --git a/ns1/rest/apikey_secret.py b/ns1/rest/apikey_secret.py new file mode 100644 index 0000000..15ac5b7 --- /dev/null +++ b/ns1/rest/apikey_secret.py @@ -0,0 +1,91 @@ +# +# Copyright (c) 2026 NSONE, Inc. +# +# License under The MIT License (MIT). See LICENSE in project root. +# +from . import resource + + +class APIKeySecret(resource.BaseResource): + ROOT = "../apikeys/v1/secrets" + + PASSTHRU_FIELDS = [ + "expires_at", + ] + + BOOL_FIELDS= [ + "enabled", + ] + + # Forward HTTP methods needed by APIKey Secrets API + def _get(self, path, params=None): + """Forward GET requests to make_request""" + # Fix path to start with /apikeys/v1/secrets/ if needed + if path.startswith("/"): + path = path[1:] # Remove leading slash + if not path.startswith("apikeys/v1/secrets/"): + # Secret endpoints should have this prefix + path = f"{self.ROOT}/{path.split('/')[-1]}" + return self._make_request("GET", path, params=params) + + def _post(self, path, json=None): + """Forward POST requests to make_request""" + if path.startswith("/"): + path = path[1:] # Remove leading slash + if not path.startswith("apikeys/v1/secrets/"): + path = f"{self.ROOT}" + return self._make_request("POST", path, body=json) + + def _patch(self, path, json=None): + """Forward PATCH requests to make_request""" + if path.startswith("/"): + path = path[1:] # Remove leading slash + if not path.startswith("apikeys/v1/secrets/"): + parts = path.split("/") + path = f"{self.ROOT}/{parts[-1]}" + return self._make_request("PATCH", path, body=json) + + def _delete(self, path): + """Forward DELETE requests to make_request""" + if path.startswith("/"): + path = path[1:] # Remove leading slash + if not path.startswith("apikeys/v1/secrets/"): + parts = path.split("/") + path = f"{self.ROOT}/{parts[-1]}" + return self._make_request("DELETE", path) + + def update(self, secret_id, callback=None, errback=None, **kwargs): + body = {} + self._buildStdBody(body, kwargs) + + return self._make_request( + "PUT", + "%s/%s" % (self.ROOT, secret_id), + body=body, + callback=callback, + errback=errback, + ) + + def retrieve(self, secret_id="self", callback=None, errback=None): + return self._make_request( + "GET", + "%s/%s" % (self.ROOT, secret_id), + callback=callback, + errback=errback, + ) + + def renew(self, secret_id="self", callback=None, errback=None): + return self._make_request( + "POST", + "%s/%s/renew" % (self.ROOT, secret_id), + callback=callback, + errback=errback, + ) + + def delete(self, secret_id, callback=None, errback=None): + return self._make_request( + "DELETE", + "%s/%s" % (self.ROOT, secret_id), + callback=callback, + errback=errback, + ) diff --git a/tests/unit/test_apikey.py b/tests/unit/test_apikey.py index ae4e651..5183e31 100644 --- a/tests/unit/test_apikey.py +++ b/tests/unit/test_apikey.py @@ -58,6 +58,18 @@ def test_rest_apikey_create(apikey_config, name, url): body={"name": name, "permissions": permissions._default_perms}, ) +@pytest.mark.parametrize("name, url", [("test-apikey-with-expiry", "account/apikeys")]) +def test_rest_apikey_create_with_expiry(apikey_config, name, url): + z = ns1.rest.apikey.APIKey(apikey_config) + z._make_request = mock.MagicMock() + z.create(name, expiry_duration = "10d") + z._make_request.assert_called_once_with( + "PUT", + url, + callback=None, + errback=None, + body={"name": name, "permissions": permissions._default_perms, "expiry_duration": "10d"}, + ) @pytest.mark.parametrize( "apikey_id, name, ip_whitelist, permissions, url", diff --git a/tests/unit/test_apikey_secrets.py b/tests/unit/test_apikey_secrets.py new file mode 100644 index 0000000..86b926b --- /dev/null +++ b/tests/unit/test_apikey_secrets.py @@ -0,0 +1,147 @@ +# +# Copyright (c) 2026 NSONE, Inc. +# +# License under The MIT License (MIT). See LICENSE in project root. +# +import ns1.rest.apikey_secret +import pytest + +try: # Python 3.3 + + import unittest.mock as mock +except ImportError: + import mock + + +@pytest.fixture +def apikey_secret_config(config): + config.loadFromDict( + { + "endpoint": "api.nsone.net", + "default_key": "test1", + "keys": { + "test1": { + "key": "key-1", + "desc": "test key number 1", + } + }, + } + ) + + return config + + +@pytest.mark.parametrize( + "secret_id, enabled, expires_at, url", + [ + ( + "secret-123", + True, + 1234567890, + "../apikeys/v1/secrets/secret-123", + ), + ( + "secret-456", + False, + None, + "../apikeys/v1/secrets/secret-456", + ), + ], +) +def test_rest_apikey_secret_update( + apikey_secret_config, secret_id, enabled, expires_at, url +): + z = ns1.rest.apikey_secret.APIKeySecret(apikey_secret_config) + z._make_request = mock.MagicMock() + + if expires_at: + z.update(secret_id, enabled=enabled, expires_at=expires_at) + z._make_request.assert_called_once_with( + "PUT", + url, + callback=None, + errback=None, + body={"enabled": enabled, "expires_at": expires_at}, + ) + else: + z.update(secret_id, enabled=enabled) + z._make_request.assert_called_once_with( + "PUT", + url, + callback=None, + errback=None, + body={"enabled": enabled}, + ) + + +@pytest.mark.parametrize( + "secret_id, url", + [ + ("secret-123", "../apikeys/v1/secrets/secret-123"), + ("secret-abc", "../apikeys/v1/secrets/secret-abc"), + ], +) +def test_rest_apikey_secret_delete(apikey_secret_config, secret_id, url): + z = ns1.rest.apikey_secret.APIKeySecret(apikey_secret_config) + z._make_request = mock.MagicMock() + z.delete(secret_id) + z._make_request.assert_called_once_with( + "DELETE", url, callback=None, errback=None + ) + +@pytest.mark.parametrize( + "secret_id, url", + [ + ("secret-123", "../apikeys/v1/secrets/secret-123"), + ("secret-abc", "../apikeys/v1/secrets/secret-abc"), + ], +) +def test_rest_apikey_secret_retrieve(apikey_secret_config, secret_id, url): + z = ns1.rest.apikey_secret.APIKeySecret(apikey_secret_config) + z._make_request = mock.MagicMock() + z.retrieve(secret_id) + z._make_request.assert_called_once_with( + "GET", url, callback=None, errback=None + ) + +@pytest.mark.parametrize( + "url", + [ + ("../apikeys/v1/secrets/self"), + ], +) +def test_rest_apikey_secret_retrieve_self(apikey_secret_config, url): + z = ns1.rest.apikey_secret.APIKeySecret(apikey_secret_config) + z._make_request = mock.MagicMock() + z.retrieve() + z._make_request.assert_called_once_with( + "GET", url, callback=None, errback=None + ) + +@pytest.mark.parametrize( + "secret_id, url", + [ + ("secret-123", "../apikeys/v1/secrets/secret-123/renew"), + ("secret-abc", "../apikeys/v1/secrets/secret-abc/renew"), + ], +) +def test_rest_apikey_secret_renew(apikey_secret_config, secret_id, url): + z = ns1.rest.apikey_secret.APIKeySecret(apikey_secret_config) + z._make_request = mock.MagicMock() + z.renew(secret_id) + z._make_request.assert_called_once_with( + "POST", url, callback=None, errback=None + ) + +@pytest.mark.parametrize( + "url", + [ + ("../apikeys/v1/secrets/self/renew"), + ], +) +def test_rest_apikey_secret_renew_self(apikey_secret_config, url): + z = ns1.rest.apikey_secret.APIKeySecret(apikey_secret_config) + z._make_request = mock.MagicMock() + z.renew() + z._make_request.assert_called_once_with( + "POST", url, callback=None, errback=None + ) From 6ace6530d80a89474cf0cfbe910e6eff2eb5f49f Mon Sep 17 00:00:00 2001 From: Howard Hellyer Date: Thu, 14 May 2026 15:15:34 +0100 Subject: [PATCH 2/6] Fix linting issues. --- examples/apikey-secret-renewal.py | 6 ++++-- ns1/rest/apikey_secret.py | 2 +- tests/unit/test_apikey.py | 4 +++- tests/unit/test_apikey_secrets.py | 4 ++++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/examples/apikey-secret-renewal.py b/examples/apikey-secret-renewal.py index 5f5c0b2..18fe583 100644 --- a/examples/apikey-secret-renewal.py +++ b/examples/apikey-secret-renewal.py @@ -6,12 +6,14 @@ from ns1 import NS1 + def print_secret(secret): print(f" Secret ID: {secret['secret_id']}") print(f" Secret Value: {secret['secret']}") print(f" Expires At: {secret['expires_at']}") print(f" Enabled: {secret['enabled']}") + # NS1 will use config in ~/.nsone by default api = NS1() @@ -44,7 +46,7 @@ def print_secret(secret): # Store the actual apikey for later operations apikey_id = apikey["id"] apikey_secret = apikey["secrets"][0] - apikey_secret_id= apikey_secret["secret_id"] + apikey_secret_is = apikey_secret["secret_id"] apikey_secret_key = apikey_secret["secret"] print("Initial api key secret:") @@ -61,7 +63,7 @@ def print_secret(secret): # The default secret id for renew() is "self" and this will # renew the secret being used for authentication. new_secret = apikeysecrets_with_secret_auth.renew() - print(f"Renewed api key secret:") + print("Renewed api key secret:") print_secret(new_secret) finally: # Clean up the API key so this script can be re-run diff --git a/ns1/rest/apikey_secret.py b/ns1/rest/apikey_secret.py index 15ac5b7..1419093 100644 --- a/ns1/rest/apikey_secret.py +++ b/ns1/rest/apikey_secret.py @@ -13,7 +13,7 @@ class APIKeySecret(resource.BaseResource): "expires_at", ] - BOOL_FIELDS= [ + BOOL_FIELDS = [ "enabled", ] diff --git a/tests/unit/test_apikey.py b/tests/unit/test_apikey.py index 5183e31..c67f731 100644 --- a/tests/unit/test_apikey.py +++ b/tests/unit/test_apikey.py @@ -58,11 +58,12 @@ def test_rest_apikey_create(apikey_config, name, url): body={"name": name, "permissions": permissions._default_perms}, ) + @pytest.mark.parametrize("name, url", [("test-apikey-with-expiry", "account/apikeys")]) def test_rest_apikey_create_with_expiry(apikey_config, name, url): z = ns1.rest.apikey.APIKey(apikey_config) z._make_request = mock.MagicMock() - z.create(name, expiry_duration = "10d") + z.create(name, expiry_duration="10d") z._make_request.assert_called_once_with( "PUT", url, @@ -71,6 +72,7 @@ def test_rest_apikey_create_with_expiry(apikey_config, name, url): body={"name": name, "permissions": permissions._default_perms, "expiry_duration": "10d"}, ) + @pytest.mark.parametrize( "apikey_id, name, ip_whitelist, permissions, url", [ diff --git a/tests/unit/test_apikey_secrets.py b/tests/unit/test_apikey_secrets.py index 86b926b..cea2557 100644 --- a/tests/unit/test_apikey_secrets.py +++ b/tests/unit/test_apikey_secrets.py @@ -88,6 +88,7 @@ def test_rest_apikey_secret_delete(apikey_secret_config, secret_id, url): "DELETE", url, callback=None, errback=None ) + @pytest.mark.parametrize( "secret_id, url", [ @@ -103,6 +104,7 @@ def test_rest_apikey_secret_retrieve(apikey_secret_config, secret_id, url): "GET", url, callback=None, errback=None ) + @pytest.mark.parametrize( "url", [ @@ -117,6 +119,7 @@ def test_rest_apikey_secret_retrieve_self(apikey_secret_config, url): "GET", url, callback=None, errback=None ) + @pytest.mark.parametrize( "secret_id, url", [ @@ -132,6 +135,7 @@ def test_rest_apikey_secret_renew(apikey_secret_config, secret_id, url): "POST", url, callback=None, errback=None ) + @pytest.mark.parametrize( "url", [ From 238d46628ca6b8abedbb1e9d32828837b4bfaa67 Mon Sep 17 00:00:00 2001 From: Howard Hellyer Date: Thu, 14 May 2026 15:27:16 +0100 Subject: [PATCH 3/6] Fix issues from next linting step. --- examples/apikey-secret-renewal.py | 10 ++++++---- tests/unit/test_apikey.py | 10 ++++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/examples/apikey-secret-renewal.py b/examples/apikey-secret-renewal.py index 18fe583..203551f 100644 --- a/examples/apikey-secret-renewal.py +++ b/examples/apikey-secret-renewal.py @@ -34,10 +34,12 @@ def print_secret(secret): # You can also specify teams, ip_whitelist, ip_whitelist_strict, and permissions # If permissions are not specified, default permissions (all false) will be used # expiry_duration is set to 30 days -apikey_id = '' +apikey_id = "" try: - print('Creating API key with 30 day expiry...') - apikey = apikey_api.create("example-api-key-with-expiry", expiry_duration="30d") + print("Creating API key with 30 day expiry...") + apikey = apikey_api.create( + "example-api-key-with-expiry", expiry_duration="30d" + ) apikey_id = apikey["id"] print(f"Created API key: {apikey_id}") @@ -67,5 +69,5 @@ def print_secret(secret): print_secret(new_secret) finally: # Clean up the API key so this script can be re-run - if apikey_id != '': + if apikey_id != "": apikey_api.delete(apikey_id) diff --git a/tests/unit/test_apikey.py b/tests/unit/test_apikey.py index c67f731..ec5f048 100644 --- a/tests/unit/test_apikey.py +++ b/tests/unit/test_apikey.py @@ -59,7 +59,9 @@ def test_rest_apikey_create(apikey_config, name, url): ) -@pytest.mark.parametrize("name, url", [("test-apikey-with-expiry", "account/apikeys")]) +@pytest.mark.parametrize( + "name, url", [("test-apikey-with-expiry", "account/apikeys")] +) def test_rest_apikey_create_with_expiry(apikey_config, name, url): z = ns1.rest.apikey.APIKey(apikey_config) z._make_request = mock.MagicMock() @@ -69,7 +71,11 @@ def test_rest_apikey_create_with_expiry(apikey_config, name, url): url, callback=None, errback=None, - body={"name": name, "permissions": permissions._default_perms, "expiry_duration": "10d"}, + body={ + "name": name, + "permissions": permissions._default_perms, + "expiry_duration": "10d", + }, ) From ba2c4cbc3eed078d6361efa8ae29320b366ded8d Mon Sep 17 00:00:00 2001 From: Howard Hellyer Date: Thu, 14 May 2026 15:30:05 +0100 Subject: [PATCH 4/6] Fix linter failure from last PR merged to master. --- ns1/rest/transport/twisted.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ns1/rest/transport/twisted.py b/ns1/rest/transport/twisted.py index 02ada9b..37c1a2c 100644 --- a/ns1/rest/transport/twisted.py +++ b/ns1/rest/transport/twisted.py @@ -82,6 +82,7 @@ def stopProducing(self): if have_twisted: + @implementer(IPolicyForHTTPS) class NoValidationPolicy(object): def creatorForNetloc(self, hostname, port): From cc3f045f67ca5253bcf36d85f05679bc777ebf64 Mon Sep 17 00:00:00 2001 From: Howard Hellyer Date: Thu, 14 May 2026 15:41:10 +0100 Subject: [PATCH 5/6] Move comment in example. --- examples/apikey-secret-renewal.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/examples/apikey-secret-renewal.py b/examples/apikey-secret-renewal.py index 203551f..70facca 100644 --- a/examples/apikey-secret-renewal.py +++ b/examples/apikey-secret-renewal.py @@ -23,10 +23,6 @@ def print_secret(secret): # to load an alternate configuration file: # api = NS1(configFile='/etc/ns1/api.json') -######################## -# CREATE API KEY # -######################## - # Get the API key interface apikey_api = api.apikey() @@ -36,6 +32,9 @@ def print_secret(secret): # expiry_duration is set to 30 days apikey_id = "" try: + ########################### + # CREATE EXPIRING API KEY # + ########################### print("Creating API key with 30 day expiry...") apikey = apikey_api.create( "example-api-key-with-expiry", expiry_duration="30d" From 31fb00acd24056f758297f15cb433e8a709b1be7 Mon Sep 17 00:00:00 2001 From: Howard Hellyer Date: Fri, 22 May 2026 15:16:54 +0100 Subject: [PATCH 6/6] Remove unused functions. --- ns1/rest/apikey_secret.py | 37 ------------------------------------- 1 file changed, 37 deletions(-) diff --git a/ns1/rest/apikey_secret.py b/ns1/rest/apikey_secret.py index 1419093..3994e3f 100644 --- a/ns1/rest/apikey_secret.py +++ b/ns1/rest/apikey_secret.py @@ -17,43 +17,6 @@ class APIKeySecret(resource.BaseResource): "enabled", ] - # Forward HTTP methods needed by APIKey Secrets API - def _get(self, path, params=None): - """Forward GET requests to make_request""" - # Fix path to start with /apikeys/v1/secrets/ if needed - if path.startswith("/"): - path = path[1:] # Remove leading slash - if not path.startswith("apikeys/v1/secrets/"): - # Secret endpoints should have this prefix - path = f"{self.ROOT}/{path.split('/')[-1]}" - return self._make_request("GET", path, params=params) - - def _post(self, path, json=None): - """Forward POST requests to make_request""" - if path.startswith("/"): - path = path[1:] # Remove leading slash - if not path.startswith("apikeys/v1/secrets/"): - path = f"{self.ROOT}" - return self._make_request("POST", path, body=json) - - def _patch(self, path, json=None): - """Forward PATCH requests to make_request""" - if path.startswith("/"): - path = path[1:] # Remove leading slash - if not path.startswith("apikeys/v1/secrets/"): - parts = path.split("/") - path = f"{self.ROOT}/{parts[-1]}" - return self._make_request("PATCH", path, body=json) - - def _delete(self, path): - """Forward DELETE requests to make_request""" - if path.startswith("/"): - path = path[1:] # Remove leading slash - if not path.startswith("apikeys/v1/secrets/"): - parts = path.split("/") - path = f"{self.ROOT}/{parts[-1]}" - return self._make_request("DELETE", path) - def update(self, secret_id, callback=None, errback=None, **kwargs): body = {} self._buildStdBody(body, kwargs)