Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
72 changes: 72 additions & 0 deletions examples/apikey-secret-renewal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#
# 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='<<CLEARTEXT API KEY>>')

# to load an alternate configuration file:
# api = NS1(configFile='/etc/ns1/api.json')

# 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:
###########################
# CREATE EXPIRING API KEY #
###########################
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_is = 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("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)
12 changes: 11 additions & 1 deletion ns1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
#
from .config import Config

version = "0.27.4"
version = "0.28.0"


class NS1:
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion ns1/rest/apikey.py
Original file line number Diff line number Diff line change
@@ -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.
#
Expand All @@ -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):
Expand Down
54 changes: 54 additions & 0 deletions ns1/rest/apikey_secret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#
# 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",
]

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,
)
1 change: 1 addition & 0 deletions ns1/rest/transport/twisted.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def stopProducing(self):


if have_twisted:

@implementer(IPolicyForHTTPS)
class NoValidationPolicy(object):
def creatorForNetloc(self, hostname, port):
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/test_apikey.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,26 @@ def test_rest_apikey_create(apikey_config, name, url):
)


@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",
[
Expand Down
151 changes: 151 additions & 0 deletions tests/unit/test_apikey_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#
# 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
)
Loading