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
91 changes: 91 additions & 0 deletions ns1/rest/apikey_secret.py
Original file line number Diff line number Diff line change
@@ -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,
)
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
Loading
Loading