diff --git a/CHANGELOG.md b/CHANGELOG.md index 4676a511..ec39d197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/go.mod b/go.mod index 5fc6cf6b..f55737b6 100644 --- a/go.mod +++ b/go.mod @@ -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 ( diff --git a/go.sum b/go.sum index 72f49a18..37a340a9 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/ns1/examples/apikey.tf b/ns1/examples/apikey.tf index d7ca864e..c64dafd3 100644 --- a/ns1/examples/apikey.tf +++ b/ns1/examples/apikey.tf @@ -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 '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 +} diff --git a/ns1/resource_apikey.go b/ns1/resource_apikey.go index db8d2df7..c9ab6bff 100644 --- a/ns1/resource_apikey.go +++ b/ns1/resource_apikey.go @@ -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" @@ -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 'd' (e.g., '10d', '30d', '90d')", + ), + Description: "Duration for automatic secret expiration (e.g., '10d', '30d', '90d'). Accepts any duration in '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) @@ -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 } @@ -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 } diff --git a/ns1/resource_apikey_test.go b/ns1/resource_apikey_test.go index 21a23174..b128614b 100644 --- a/ns1/resource_apikey_test.go +++ b/ns1/resource_apikey_test.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "reflect" + "regexp" "sort" "testing" @@ -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"), + 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 'd'`), + }, + { + Config: testAccAPIKeyWithExpiryDuration(name, "30"), + ExpectError: regexp.MustCompile(`must be a duration in format 'd'`), + }, + { + Config: testAccAPIKeyWithExpiryDuration(name, "d30"), + ExpectError: regexp.MustCompile(`must be a duration in format '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" diff --git a/website/docs/r/apikey.html.markdown b/website/docs/r/apikey.html.markdown index b9a50a8e..54471d08 100644 --- a/website/docs/r/apikey.html.markdown +++ b/website/docs/r/apikey.html.markdown @@ -12,24 +12,51 @@ Provides a NS1 Api Key resource. This can be used to create, modify, and delete ## Example Usage +### Legacy API Key (Static Secret) + ```hcl -resource "ns1_team" "example" { - name = "Example team" +resource "ns1_apikey" "static_key" { + name = "Static API Key" + + # Configure permissions + dns_view_zones = true + dns_manage_zones = true } -resource "ns1_apikey" "example" { - name = "Example key" - teams = [ns1_team.example.id] +# The static secret is available in the key attribute +output "api_key_secret" { + value = ns1_apikey.static_key.key + sensitive = true +} +``` + +### API Key with Secret Expiration - # Optional IP whitelist - ip_whitelist = ["1.1.1.1","2.2.2.2"] +```hcl +resource "ns1_apikey" "expiring" { + name = "Expiring API Key" + + # Set secret expiration period to 30 days + # Secrets will expire after this period and must be manually rotated + # Accepts any duration in 'd' format (e.g., "10d", "30d", "90d") + expiry_duration = "30d" # Configure permissions - dns_view_zones = false - account_manage_users = false + dns_view_zones = true + dns_manage_zones = true +} + +# Access secret metadata (not the actual secret key values) +output "secret_info" { + value = ns1_apikey.expiring.secrets + sensitive = true } ``` +## Important Notes + +~> **Changing expiry_duration forces recreation.** When you modify the `expiry_duration` field of an existing API key, Terraform will destroy the old key and create a new one. This means the API key ID and all secrets will change. Any external references to the old key will break. Plan your migrations carefully and update dependent systems before changing this value. + ## Permissions An API key will inherit permissions from the teams it is assigned to. If a key is assigned to a team and also has individual permissions set on the key, the individual permissions @@ -50,6 +77,7 @@ The following arguments are supported: * `teams` - (Optional) The teams that the apikey belongs to. * `ip_whitelist` - (Optional, default: `[]`) Array of IP addresses/networks to which to grant the API key access. * `ip_whitelist_strict` - (Optional, default: `false`) Set to true to restrict access to only those IP addresses and networks listed in the **ip_whitelist** field. +* `expiry_duration` - (Optional) Duration for secret expiration in `d` format (e.g., `"10d"`, `"30d"`, `"90d"`). When set, API key secrets will expire after the specified period and must be manually rotated using the NS1 API or Portal. The API key can have up to 2 active secrets at a time to allow for graceful rotation without service interruption. If not set, a legacy API key with a permanent secret (stored in the `key` attribute) is created. Changing this value will force recreation of the API key. * `dns_view_zones` - (Optional, default: `false`) Whether the apikey can view the accounts zones. * `dns_manage_zones` - (Optional, default: `false`) Whether the apikey can modify the accounts zones. * `dns_zones_allow_by_default` - (Optional, default: `false`) If true, enable the `dns_zones_allow` list, otherwise enable the `dns_zones_deny` list. @@ -85,7 +113,14 @@ The following arguments are supported: In addition to all arguments above, the following attributes are exported: -* `key` - (Computed) The apikeys authentication token. +* `key` - (Computed) The API key authentication token. Only populated for legacy API keys (when `expiry_duration` is not set). For API keys with expiration, use the secret keys from the `secrets` attribute instead. +* `secrets` - (Computed) List of secrets for this API key. Only populated when `expiry_duration` is set. Each secret contains: + * `id` - The unique identifier for the secret. + * `expires_at` - The expiration date/time of the secret in ISO 8601 format. + * `last_access` - The last time this secret was used for authentication. + * `enabled` - Whether this secret is currently enabled for authentication. + +**Note:** The actual secret key values (starting with `nss_`) are only returned when a secret is first created and are not stored in Terraform state for security reasons. You must save these values when they are first created, as they cannot be retrieved later. To rotate secrets (generate new ones), use the NS1 API or Portal - Terraform does not manage secret rotation. ## Import