Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 2.9.0 (June 11, 2026)
ENHANCEMENTS
* Add support for API key secret expiration via `expiry_duration` attribute
* Add computed `secrets` attribute to view secret metadata for API keys with expiration
* Update ns1-go SDK to v2.18.0

## 2.8.2 (February 18 2026)
ENHANCEMENTS
* Add insights permissions
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.7
github.com/hashicorp/terraform-plugin-sdk/v2 v2.24.1
github.com/stretchr/testify v1.8.1
gopkg.in/ns1/ns1-go.v2 v2.17.2
gopkg.in/ns1/ns1-go.v2 v2.18.0
)

require (
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,8 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ns1/ns1-go.v2 v2.17.2 h1:x8YKHqCJWkC/hddfUhw7FRqTG0x3fr/0ZnWYN+i4THs=
gopkg.in/ns1/ns1-go.v2 v2.17.2/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc=
gopkg.in/ns1/ns1-go.v2 v2.18.0 h1:dz5QvZGd9nLSp7hktmJwXgCI2L7pcRN/mtZxyNim370=
gopkg.in/ns1/ns1-go.v2 v2.18.0/go.mod h1:hDx6jxNZBmEOkpV8buB2eR1rxlgYUgRDT3nRHi8RWWc=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
Expand Down
20 changes: 20 additions & 0 deletions ns1/examples/apikey.tf
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,23 @@ resource "ns1_apikey" "apikey" {

#permissions are available at the top level
}

# Example: API key with secret expiration
resource "ns1_apikey" "expiring_key" {
name = "expiring-api-key"

# Set secret expiration period to 30 days
# Secrets will expire after this period and must be manually renewed
# Accepts any duration in '<number>d' format (e.g., "10d", "30d", "90d")
expiry_duration = "30d"

# Configure permissions
dns_view_zones = true
dns_manage_zones = true
}

# The secrets metadata can be viewed in the state
output "expiring_key_secrets" {
value = ns1_apikey.expiring_key.secrets
sensitive = true
}
75 changes: 75 additions & 0 deletions ns1/resource_apikey.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package ns1

import (
"fmt"
"log"
"regexp"

"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"

ns1 "gopkg.in/ns1/ns1-go.v2/rest"
"gopkg.in/ns1/ns1-go.v2/rest/model/account"
Expand Down Expand Up @@ -35,6 +38,45 @@ func apikeyResource() *schema.Resource {
Optional: true,
Default: false,
},
"expiry_duration": {
Type: schema.TypeString,
Optional: true,
ForceNew: true,
ValidateFunc: validation.StringMatch(
regexp.MustCompile(`^\d+d$`),
"must be a duration in format '<number>d' (e.g., '10d', '30d', '90d')",
),
Description: "Duration for automatic secret expiration (e.g., '10d', '30d', '90d'). Accepts any duration in '<number>d' format. When set, API key secrets will automatically expire after the specified period. Changing this value will force recreation of the API key.",
},
"secrets": {
Type: schema.TypeList,
Computed: true,
Description: "List of secrets associated with this API key when expiry_duration is set.",
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"id": {
Type: schema.TypeString,
Computed: true,
Description: "The unique identifier for this secret.",
},
"expires_at": {
Type: schema.TypeString,
Computed: true,
Description: "The expiration date/time of this secret in ISO 8601 format.",
},
"last_access": {
Type: schema.TypeString,
Computed: true,
Description: "The last time this secret was used for authentication.",
},
"enabled": {
Type: schema.TypeBool,
Computed: true,
Description: "Whether this secret is currently enabled for authentication.",
},
},
},
},
}

s = addPermsSchema(s)
Expand Down Expand Up @@ -70,6 +112,34 @@ func apikeyToResourceData(d *schema.ResourceData, k *account.APIKey) error {
d.Set("key", k.Key)
}

// Set expiry_duration if present
if k.ExpiryDuration != "" {
if err := d.Set("expiry_duration", k.ExpiryDuration); err != nil {
return fmt.Errorf("error setting expiry_duration: %w", err)
}
}

// Set secrets if present
if len(k.Secrets) > 0 {
secrets := make([]map[string]interface{}, len(k.Secrets))
for i, secret := range k.Secrets {
secretMap := map[string]interface{}{
"id": secret.ID,
"expires_at": secret.ExpiresAt,
}
if secret.LastAccess != "" {
secretMap["last_access"] = secret.LastAccess
}
if secret.Enabled != nil {
secretMap["enabled"] = *secret.Enabled
}
secrets[i] = secretMap
}
if err := d.Set("secrets", secrets); err != nil {
return fmt.Errorf("error setting secrets: %w", err)
}
}

return nil
}

Expand Down Expand Up @@ -102,6 +172,11 @@ func resourceDataToApikey(k *account.APIKey, d *schema.ResourceData) error {

k.IPWhitelistStrict = d.Get("ip_whitelist_strict").(bool)

// Set expiry_duration if provided
if v, ok := d.GetOk("expiry_duration"); ok {
k.ExpiryDuration = v.(string)
}

return nil
}

Expand Down
177 changes: 177 additions & 0 deletions ns1/resource_apikey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"log"
"reflect"
"regexp"
"sort"
"testing"

Expand Down Expand Up @@ -375,6 +376,182 @@ resource "ns1_apikey" "it" {
`, rString, rString)
}

func TestAccAPIKey_withExpiryDuration(t *testing.T) {
var apiKey account.APIKey
name := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum)

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
CheckDestroy: testAccCheckAPIKeyDestroy,
Steps: []resource.TestStep{
{
Config: testAccAPIKeyWithExpiryDuration(name, "30d"),
Check: resource.ComposeTestCheckFunc(
testAccCheckAPIKeyExists("ns1_apikey.it", &apiKey),
testAccCheckAPIKeyName(&apiKey, name),
resource.TestCheckResourceAttr("ns1_apikey.it", "expiry_duration", "30d"),
testAccCheckAPIKeyHasSecrets(&apiKey),
),
},
{
ResourceName: "ns1_apikey.it",
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{"key", "secrets"}, // importing existing key won't have the key or secret values
},
},
})
}

func TestAccAPIKey_updateExpiryDuration(t *testing.T) {
var apiKey account.APIKey
var apiKeyRecreated account.APIKey
name := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum)

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
CheckDestroy: testAccCheckAPIKeyDestroy,
Steps: []resource.TestStep{
{
Config: testAccAPIKeyBasic(name),
Check: resource.ComposeTestCheckFunc(
testAccCheckAPIKeyExists("ns1_apikey.it", &apiKey),
testAccCheckAPIKeyName(&apiKey, name),
resource.TestCheckResourceAttr("ns1_apikey.it", "expiry_duration", ""),
),
},
{
Config: testAccAPIKeyWithExpiryDuration(name, "30d"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this testing? I don't think we can change the expiry duration on an api key once it's created.
(Edit - I realised that since terraform will recreate the object this probably does work, I'm not sure it's much use because of the key value changing. I think this is probably fine as it's just the way terraform works, customers should probably avoid doing it but we already have this for normal keys above.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Terraform recreates the resource when expiry_duration changes, technically the update works, but the API key/secrets will change as part of the recreation. I added a note in the docs to make that behaviour clearer

Check: resource.ComposeTestCheckFunc(
testAccCheckAPIKeyExists("ns1_apikey.it", &apiKeyRecreated),
testAccCheckAPIKeyName(&apiKeyRecreated, name),
resource.TestCheckResourceAttr("ns1_apikey.it", "expiry_duration", "30d"),
testAccCheckAPIKeyHasSecrets(&apiKeyRecreated),
// Verify the API key was recreated (different ID)
testAccCheckAPIKeyRecreated(&apiKey, &apiKeyRecreated),
),
},
},
})
}

func testAccCheckAPIKeyHasSecrets(k *account.APIKey) resource.TestCheckFunc {
return func(s *terraform.State) error {
if len(k.Secrets) == 0 {
return fmt.Errorf("API key should have at least one secret when expiry_duration is set")
}

// Verify the first secret has required fields
secret := k.Secrets[0]
if secret.ID == "" {
return fmt.Errorf("secret should have an ID")
}
if secret.ExpiresAt == "" {
return fmt.Errorf("secret should have an expiration date")
}
if secret.Enabled == nil {
return fmt.Errorf("secret should have an enabled status")
}

return nil
}
}

func testAccCheckAPIKeyRecreated(old, new *account.APIKey) resource.TestCheckFunc {
return func(s *terraform.State) error {
if old.ID == new.ID {
return fmt.Errorf("expected API key to be recreated with new ID, but got same ID: %s", old.ID)
}
return nil
}
}

func testAccAPIKeyWithExpiryDuration(apiKeyName, expiryDuration string) string {
return fmt.Sprintf(`resource "ns1_apikey" "it" {
name = "%s"
expiry_duration = "%s"

dns_view_zones = false
account_manage_users = false
}
`, apiKeyName, expiryDuration)
}
func TestAccAPIKey_invalidExpiryDuration(t *testing.T) {
name := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum)

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
CheckDestroy: testAccCheckAPIKeyDestroy,
Steps: []resource.TestStep{
{
Config: testAccAPIKeyWithExpiryDuration(name, "invalid"),
ExpectError: regexp.MustCompile(`must be a duration in format '<number>d'`),
},
{
Config: testAccAPIKeyWithExpiryDuration(name, "30"),
ExpectError: regexp.MustCompile(`must be a duration in format '<number>d'`),
},
{
Config: testAccAPIKeyWithExpiryDuration(name, "d30"),
ExpectError: regexp.MustCompile(`must be a duration in format '<number>d'`),
},
},
})
}

func TestAccAPIKey_allExpiryDurations(t *testing.T) {
durations := []string{"10d", "30d", "90d"}

for _, duration := range durations {
t.Run(duration, func(t *testing.T) {
var apiKey account.APIKey
name := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum)

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
CheckDestroy: testAccCheckAPIKeyDestroy,
Steps: []resource.TestStep{
{
Config: testAccAPIKeyWithExpiryDuration(name, duration),
Check: resource.ComposeTestCheckFunc(
testAccCheckAPIKeyExists("ns1_apikey.it", &apiKey),
testAccCheckAPIKeyName(&apiKey, name),
resource.TestCheckResourceAttr("ns1_apikey.it", "expiry_duration", duration),
testAccCheckAPIKeyHasSecrets(&apiKey),
),
},
},
})
})
}
}

func TestAccAPIKey_customExpiryDuration(t *testing.T) {
var apiKey account.APIKey
name := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum)

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
CheckDestroy: testAccCheckAPIKeyDestroy,
Steps: []resource.TestStep{
{
Config: testAccAPIKeyWithExpiryDuration(name, "45d"),
Check: resource.ComposeTestCheckFunc(
testAccCheckAPIKeyExists("ns1_apikey.it", &apiKey),
testAccCheckAPIKeyName(&apiKey, name),
resource.TestCheckResourceAttr("ns1_apikey.it", "expiry_duration", "45d"),
testAccCheckAPIKeyHasSecrets(&apiKey),
),
},
},
})
}

func testAccAPIKeyPermissionsNoTeam(rString string) string {
return fmt.Sprintf(`resource "ns1_team" "t" {
name = "terraform acc test team %s"
Expand Down
Loading