From 55a2468353c83cb6886837f802ebefc005b4cba8 Mon Sep 17 00:00:00 2001 From: Sivaanand Murugesan Date: Wed, 18 Dec 2024 16:23:21 +0530 Subject: [PATCH] PLT-1523: Added password_policy support in terraform. --- docs/resources/password_policy.md | 59 +++++ .../spectrocloud_password_policy/providers.tf | 14 ++ .../spectrocloud_password_policy/resource.tf | 10 + .../terraform.template.tfvars | 4 + .../spectrocloud_password_policy/variables.tf | 18 ++ go.mod | 2 +- spectrocloud/cluster_common.go | 5 + spectrocloud/provider.go | 11 +- spectrocloud/resource_password_policy.go | 220 ++++++++++++++++++ spectrocloud/resource_password_policy_test.go | 167 +++++++++++++ templates/resources/password_policy.md.tmpl | 33 +++ 11 files changed, 537 insertions(+), 6 deletions(-) create mode 100644 docs/resources/password_policy.md create mode 100644 examples/resources/spectrocloud_password_policy/providers.tf create mode 100644 examples/resources/spectrocloud_password_policy/resource.tf create mode 100644 examples/resources/spectrocloud_password_policy/terraform.template.tfvars create mode 100644 examples/resources/spectrocloud_password_policy/variables.tf create mode 100644 spectrocloud/resource_password_policy.go create mode 100644 spectrocloud/resource_password_policy_test.go create mode 100644 templates/resources/password_policy.md.tmpl diff --git a/docs/resources/password_policy.md b/docs/resources/password_policy.md new file mode 100644 index 00000000..24045f6e --- /dev/null +++ b/docs/resources/password_policy.md @@ -0,0 +1,59 @@ +--- +page_title: "spectrocloud_password_policy Resource - terraform-provider-spectrocloud" +subcategory: "" +description: |- + +--- + +# spectrocloud_password_policy (Resource) + + + +You can learn more about managing password policy in Palette by reviewing the [Password Policy](https://docs.spectrocloud.com/enterprise-version/system-management/account-management/credentials/#password-requirements-and-security) guide. + +~> The password_policy resource enforces a password compliance policy. By default, a password policy is configured in Palette with default values. Users can update the password compliance settings as per their requirements. When a spectrocloud_password_policy resource is destroyed, the password policy will revert to the Palette default settings. + +## Example Usage + +An example of managing an password policy in Palette. + +```hcl +resource "spectrocloud_password_policy" "policy_regex" { + # password_regex = "*" + password_expiry_days = 123 + first_reminder_days = 5 + min_digits = 1 + min_lowercase_letters = 12 + min_password_length = 12 + min_special_characters = 1 + min_uppercase_letters = 1 +} +``` + + +## Schema + +### Optional + +- `first_reminder_days` (Number) The number of days before the password expiry to send the first reminder to the user. Default is 5 days before expiry. +- `min_digits` (Number) The minimum number of numeric digits (0-9) required in the password. Ensures that passwords contain numerical characters. +- `min_lowercase_letters` (Number) The minimum number of lowercase letters (a-z) required in the password. Ensures that lowercase characters are included for password complexity. +- `min_password_length` (Number) The minimum length required for the password. Enforces a stronger password policy by ensuring a minimum number of characters. +- `min_special_characters` (Number) The minimum number of special characters (e.g., !, @, #, $, %) required in the password. This increases the password's security level by including symbols. +- `min_uppercase_letters` (Number) The minimum number of uppercase letters (A-Z) required in the password. Helps ensure password complexity with a mix of case-sensitive characters. +- `password_expiry_days` (Number) The number of days before the password expires. Must be between 1 and 1000 days. Defines how often passwords must be changed. +- `password_regex` (String) A regular expression (regex) to define custom password patterns, such as enforcing specific characters or sequences in the password. +- `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts)) + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `timeouts` + +Optional: + +- `create` (String) +- `delete` (String) +- `update` (String) \ No newline at end of file diff --git a/examples/resources/spectrocloud_password_policy/providers.tf b/examples/resources/spectrocloud_password_policy/providers.tf new file mode 100644 index 00000000..4c109161 --- /dev/null +++ b/examples/resources/spectrocloud_password_policy/providers.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + spectrocloud = { + version = ">= 0.1" + source = "spectrocloud/spectrocloud" + } + } +} + +provider "spectrocloud" { + host = var.sc_host + api_key = var.sc_api_key + project_name = var.sc_project_name +} diff --git a/examples/resources/spectrocloud_password_policy/resource.tf b/examples/resources/spectrocloud_password_policy/resource.tf new file mode 100644 index 00000000..507e0bd7 --- /dev/null +++ b/examples/resources/spectrocloud_password_policy/resource.tf @@ -0,0 +1,10 @@ +resource "spectrocloud_password_policy" "policy_regex" { + # password_regex = "*" + password_expiry_days = 123 + first_reminder_days = 5 + min_digits = 1 + min_lowercase_letters = 12 + min_password_length = 12 + min_special_characters = 1 + min_uppercase_letters = 1 +} \ No newline at end of file diff --git a/examples/resources/spectrocloud_password_policy/terraform.template.tfvars b/examples/resources/spectrocloud_password_policy/terraform.template.tfvars new file mode 100644 index 00000000..c7e9d50b --- /dev/null +++ b/examples/resources/spectrocloud_password_policy/terraform.template.tfvars @@ -0,0 +1,4 @@ +# Spectro Cloud credentials +sc_host = "{Enter Spectro Cloud API Host}" #e.g: api.spectrocloud.com (for SaaS) +sc_api_key = "{Enter Spectro Cloud API Key}" +sc_project_name = "{Enter Spectro Cloud Project Name}" #e.g: Default \ No newline at end of file diff --git a/examples/resources/spectrocloud_password_policy/variables.tf b/examples/resources/spectrocloud_password_policy/variables.tf new file mode 100644 index 00000000..7bebf92c --- /dev/null +++ b/examples/resources/spectrocloud_password_policy/variables.tf @@ -0,0 +1,18 @@ +variable "sc_host" { + description = "Spectro Cloud Endpoint" + default = "api.spectrocloud.com" +} + +variable "sc_api_key" { + description = "Spectro Cloud API key" +} + +variable "sc_project_name" { + description = "Spectro Cloud Project (e.g: Default)" + default = "Default" +} + +variable "ssh_key_value" { + description = "ssh key value" + default = "ssh-rsa ...... == test@test.com" +} \ No newline at end of file diff --git a/go.mod b/go.mod index 4b891bbe..b00d174a 100644 --- a/go.mod +++ b/go.mod @@ -126,4 +126,4 @@ require ( sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect ) -//replace github.com/spectrocloud/palette-sdk-go => ../palette-sdk-go +replace github.com/spectrocloud/palette-sdk-go => ../palette-sdk-go diff --git a/spectrocloud/cluster_common.go b/spectrocloud/cluster_common.go index 96747eeb..1fe5ef69 100644 --- a/spectrocloud/cluster_common.go +++ b/spectrocloud/cluster_common.go @@ -30,6 +30,11 @@ var ( //clusterVsphereKeys = []string{"name", "context", "tags", "description", "cluster_meta_attribute", "cluster_profile", "apply_setting", "cloud_account_id", "cloud_config_id", "review_repave_state", "pause_agent_upgrades", "os_patch_on_boot", "os_patch_schedule", "os_patch_after", "kubeconfig", "admin_kube_config", "cloud_config", "machine_pool", "backup_policy", "scan_policy", "cluster_rbac_binding", "namespaces", "host_config", "location_config", "skip_completion", "force_delete", "force_delete_delay"} ) +const ( + tenantString = "tenant" + projectString = "project" +) + func toNtpServers(in map[string]interface{}) []string { servers := make([]string, 0, 1) if _, ok := in["ntp_servers"]; ok { diff --git a/spectrocloud/provider.go b/spectrocloud/provider.go index edb24151..a15c6faf 100644 --- a/spectrocloud/provider.go +++ b/spectrocloud/provider.go @@ -137,11 +137,12 @@ func New(_ string) func() *schema.Provider { "spectrocloud_appliance": resourceAppliance(), - "spectrocloud_workspace": resourceWorkspace(), - "spectrocloud_alert": resourceAlert(), - "spectrocloud_ssh_key": resourceSSHKey(), - "spectrocloud_user": resourceUser(), - "spectrocloud_role": resourceRole(), + "spectrocloud_workspace": resourceWorkspace(), + "spectrocloud_alert": resourceAlert(), + "spectrocloud_ssh_key": resourceSSHKey(), + "spectrocloud_user": resourceUser(), + "spectrocloud_role": resourceRole(), + "spectrocloud_password_policy": resourcePasswordPolicy(), }, DataSourcesMap: map[string]*schema.Resource{ "spectrocloud_permission": dataSourcePermission(), diff --git a/spectrocloud/resource_password_policy.go b/spectrocloud/resource_password_policy.go new file mode 100644 index 00000000..b73216c4 --- /dev/null +++ b/spectrocloud/resource_password_policy.go @@ -0,0 +1,220 @@ +package spectrocloud + +import ( + "context" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/spectrocloud/palette-sdk-go/api/models" + "time" +) + +func resourcePasswordPolicy() *schema.Resource { + return &schema.Resource{ + CreateContext: resourcePasswordPolicyCreate, + ReadContext: resourcePasswordPolicyRead, + UpdateContext: resourcePasswordPolicyUpdate, + DeleteContext: resourcePasswordPolicyDelete, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(10 * time.Minute), + Update: schema.DefaultTimeout(10 * time.Minute), + Delete: schema.DefaultTimeout(10 * time.Minute), + }, + SchemaVersion: 2, + Schema: map[string]*schema.Schema{ + "password_regex": { + Type: schema.TypeString, + Optional: true, + Default: "", + ConflictsWith: []string{"min_password_length", "min_uppercase_letters", + "min_digits", "min_lowercase_letters", "min_special_characters"}, + RequiredWith: []string{"password_expiry_days", "first_reminder_days"}, + Description: "A regular expression (regex) to define custom password patterns, such as enforcing specific characters or sequences in the password.", + }, + "password_expiry_days": { + Type: schema.TypeInt, + Optional: true, + Default: 999, + ValidateFunc: validation.IntBetween(1, 1000), + Description: "The number of days before the password expires. Must be between 1 and 1000 days. Defines how often passwords must be changed.", + }, + "first_reminder_days": { + Type: schema.TypeInt, + Optional: true, + Default: 5, + Description: "The number of days before the password expiry to send the first reminder to the user. Default is 5 days before expiry.", + }, + "min_password_length": { + Type: schema.TypeInt, + Optional: true, + Default: 12, + Description: "The minimum length required for the password. Enforces a stronger password policy by ensuring a minimum number of characters.", + }, + "min_uppercase_letters": { + Type: schema.TypeInt, + Optional: true, + Default: 1, + Description: "The minimum number of uppercase letters (A-Z) required in the password. Helps ensure password complexity with a mix of case-sensitive characters.", + }, + "min_digits": { + Type: schema.TypeInt, + Optional: true, + Default: 1, + Description: "The minimum number of numeric digits (0-9) required in the password. Ensures that passwords contain numerical characters.", + }, + "min_lowercase_letters": { + Type: schema.TypeInt, + Optional: true, + Default: 1, + Description: "The minimum number of lowercase letters (a-z) required in the password. Ensures that lowercase characters are included for password complexity.", + }, + "min_special_characters": { + Type: schema.TypeInt, + Optional: true, + Default: 1, + Description: "The minimum number of special characters (e.g., !, @, #, $, %) required in the password. This increases the password's security level by including symbols.", + }, + }, + } +} + +func toPasswordPolicy(d *schema.ResourceData) (*models.V1TenantPasswordPolicyEntity, error) { + if d.Get("password_regex").(string) != "" { + return &models.V1TenantPasswordPolicyEntity{ + IsRegex: true, + Regex: d.Get("password_regex").(string), + ExpiryDurationInDays: int64(d.Get("password_expiry_days").(int)), + FirstReminderInDays: int64(d.Get("first_reminder_days").(int)), + }, nil + } + return &models.V1TenantPasswordPolicyEntity{ + ExpiryDurationInDays: int64(d.Get("password_expiry_days").(int)), + FirstReminderInDays: int64(d.Get("first_reminder_days").(int)), + IsRegex: false, + MinLength: int64(d.Get("min_password_length").(int)), + MinNumOfBlockLetters: int64(d.Get("min_uppercase_letters").(int)), + MinNumOfDigits: int64(d.Get("min_digits").(int)), + MinNumOfSmallLetters: int64(d.Get("min_lowercase_letters").(int)), + MinNumOfSpecialCharacters: int64(d.Get("min_special_characters").(int)), + Regex: "", + }, nil +} + +func toPasswordPolicyDefault(d *schema.ResourceData) (*models.V1TenantPasswordPolicyEntity, error) { + return &models.V1TenantPasswordPolicyEntity{ + ExpiryDurationInDays: 999, + FirstReminderInDays: 5, + IsRegex: false, + MinLength: 6, + MinNumOfBlockLetters: 1, + MinNumOfDigits: 1, + MinNumOfSmallLetters: 1, + MinNumOfSpecialCharacters: 1, + Regex: "", + }, nil +} + +func flattenPasswordPolicy(passwordPolicy *models.V1TenantPasswordPolicyEntity, d *schema.ResourceData) error { + var err error + err = d.Set("password_regex", passwordPolicy.Regex) + if err != nil { + return err + } + err = d.Set("password_expiry_days", passwordPolicy.ExpiryDurationInDays) + if err != nil { + return err + } + err = d.Set("first_reminder_days", passwordPolicy.FirstReminderInDays) + if err != nil { + return err + } + err = d.Set("min_password_length", passwordPolicy.MinLength) + if err != nil { + return err + } + err = d.Set("min_uppercase_letters", passwordPolicy.MinNumOfBlockLetters) + if err != nil { + return err + } + err = d.Set("min_digits", passwordPolicy.MinNumOfDigits) + if err != nil { + return err + } + err = d.Set("min_lowercase_letters", passwordPolicy.MinNumOfSmallLetters) + if err != nil { + return err + } + err = d.Set("min_special_characters", passwordPolicy.MinNumOfSpecialCharacters) + if err != nil { + return err + } + return nil +} + +func resourcePasswordPolicyCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := getV1ClientWithResourceContext(m, tenantString) + var diags diag.Diagnostics + passwordPolicy, err := toPasswordPolicy(d) + if err != nil { + return diag.FromErr(err) + } + tenantUID, err := c.GetTenantUID() + if err != nil { + return diag.FromErr(err) + } + // For Password Policy we don't have support for creation it's always an update + err = c.UpdatePasswordPolicy(tenantUID, passwordPolicy) + if err != nil { + return diag.FromErr(err) + } + d.SetId("default-password-policy-id") + return diags +} + +func resourcePasswordPolicyRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + //c := getV1ClientWithResourceContext(m, tenantString) + var diags diag.Diagnostics + return diags +} + +func resourcePasswordPolicyUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := getV1ClientWithResourceContext(m, tenantString) + var diags diag.Diagnostics + passwordPolicy, err := toPasswordPolicy(d) + if err != nil { + return diag.FromErr(err) + } + tenantUID, err := c.GetTenantUID() + if err != nil { + return diag.FromErr(err) + } + // For Password Policy we don't have support for creation it's always an update + err = c.UpdatePasswordPolicy(tenantUID, passwordPolicy) + if err != nil { + return diag.FromErr(err) + } + + return diags +} + +func resourcePasswordPolicyDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := getV1ClientWithResourceContext(m, tenantString) + var diags diag.Diagnostics + // We can't delete the base password policy, instead + passwordPolicy, err := toPasswordPolicyDefault(d) + if err != nil { + return diag.FromErr(err) + } + tenantUID, err := c.GetTenantUID() + if err != nil { + return diag.FromErr(err) + } + // For Password Policy we don't have support for creation it's always an update + err = c.UpdatePasswordPolicy(tenantUID, passwordPolicy) + if err != nil { + return diag.FromErr(err) + } + d.SetId("") + return diags +} diff --git a/spectrocloud/resource_password_policy_test.go b/spectrocloud/resource_password_policy_test.go new file mode 100644 index 00000000..1970ccfc --- /dev/null +++ b/spectrocloud/resource_password_policy_test.go @@ -0,0 +1,167 @@ +package spectrocloud + +import ( + "github.com/spectrocloud/palette-sdk-go/api/models" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/stretchr/testify/assert" +) + +func TestToPasswordPolicy(t *testing.T) { + resourceSchema := map[string]*schema.Schema{ + "password_regex": { + Type: schema.TypeString, + Optional: true, + }, + "password_expiry_days": { + Type: schema.TypeInt, + Optional: true, + }, + "first_reminder_days": { + Type: schema.TypeInt, + Optional: true, + }, + "min_password_length": { + Type: schema.TypeInt, + Optional: true, + }, + "min_uppercase_letters": { + Type: schema.TypeInt, + Optional: true, + }, + "min_digits": { + Type: schema.TypeInt, + Optional: true, + }, + "min_lowercase_letters": { + Type: schema.TypeInt, + Optional: true, + }, + "min_special_characters": { + Type: schema.TypeInt, + Optional: true, + }, + } + + testCases := []struct { + name string + input map[string]interface{} + expected *models.V1TenantPasswordPolicyEntity + expectError bool + }{ + { + name: "Password regex defined", + input: map[string]interface{}{ + "password_regex": "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d).+$", + "password_expiry_days": 90, + "first_reminder_days": 10, + }, + expected: &models.V1TenantPasswordPolicyEntity{ + IsRegex: true, + Regex: "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d).+$", + ExpiryDurationInDays: 90, + FirstReminderInDays: 10, + }, + expectError: false, + }, + { + name: "No regex, full policy specified", + input: map[string]interface{}{ + "password_regex": "", + "password_expiry_days": 90, + "first_reminder_days": 10, + "min_password_length": 12, + "min_uppercase_letters": 2, + "min_digits": 3, + "min_lowercase_letters": 4, + "min_special_characters": 1, + }, + expected: &models.V1TenantPasswordPolicyEntity{ + IsRegex: false, + Regex: "", + ExpiryDurationInDays: 90, + FirstReminderInDays: 10, + MinLength: 12, + MinNumOfBlockLetters: 2, + MinNumOfDigits: 3, + MinNumOfSmallLetters: 4, + MinNumOfSpecialCharacters: 1, + }, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resourceData := schema.TestResourceDataRaw(t, resourceSchema, tc.input) + result, err := toPasswordPolicy(resourceData) + + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + } + }) + } +} + +func TestToPasswordPolicyDefault(t *testing.T) { + resourceSchema := map[string]*schema.Schema{} + + resourceData := schema.TestResourceDataRaw(t, resourceSchema, map[string]interface{}{}) + result, err := toPasswordPolicyDefault(resourceData) + + assert.NoError(t, err) + expected := &models.V1TenantPasswordPolicyEntity{ + ExpiryDurationInDays: 999, + FirstReminderInDays: 5, + IsRegex: false, + MinLength: 6, + MinNumOfBlockLetters: 1, + MinNumOfDigits: 1, + MinNumOfSmallLetters: 1, + MinNumOfSpecialCharacters: 1, + Regex: "", + } + assert.Equal(t, expected, result) +} + +func TestFlattenPasswordPolicy(t *testing.T) { + resourceSchema := map[string]*schema.Schema{ + "password_regex": {Type: schema.TypeString, Optional: true}, + "password_expiry_days": {Type: schema.TypeInt, Optional: true}, + "first_reminder_days": {Type: schema.TypeInt, Optional: true}, + "min_password_length": {Type: schema.TypeInt, Optional: true}, + "min_uppercase_letters": {Type: schema.TypeInt, Optional: true}, + "min_digits": {Type: schema.TypeInt, Optional: true}, + "min_lowercase_letters": {Type: schema.TypeInt, Optional: true}, + "min_special_characters": {Type: schema.TypeInt, Optional: true}, + } + + resourceData := schema.TestResourceDataRaw(t, resourceSchema, map[string]interface{}{}) + + passwordPolicy := &models.V1TenantPasswordPolicyEntity{ + Regex: "^[a-zA-Z0-9]+$", + ExpiryDurationInDays: 90, + FirstReminderInDays: 10, + MinLength: 8, + MinNumOfBlockLetters: 2, + MinNumOfDigits: 2, + MinNumOfSmallLetters: 2, + MinNumOfSpecialCharacters: 1, + } + + err := flattenPasswordPolicy(passwordPolicy, resourceData) + assert.NoError(t, err) + + assert.Equal(t, "^[a-zA-Z0-9]+$", resourceData.Get("password_regex")) + assert.Equal(t, 90, resourceData.Get("password_expiry_days")) + assert.Equal(t, 10, resourceData.Get("first_reminder_days")) + assert.Equal(t, 8, resourceData.Get("min_password_length")) + assert.Equal(t, 2, resourceData.Get("min_uppercase_letters")) + assert.Equal(t, 2, resourceData.Get("min_digits")) + assert.Equal(t, 2, resourceData.Get("min_lowercase_letters")) + assert.Equal(t, 1, resourceData.Get("min_special_characters")) +} diff --git a/templates/resources/password_policy.md.tmpl b/templates/resources/password_policy.md.tmpl new file mode 100644 index 00000000..6937b5a4 --- /dev/null +++ b/templates/resources/password_policy.md.tmpl @@ -0,0 +1,33 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} + +You can learn more about managing password policy in Palette by reviewing the [Password Policy](https://docs.spectrocloud.com/enterprise-version/system-management/account-management/credentials/#password-requirements-and-security) guide. + +~> The password_policy resource enforces a password compliance policy. By default, a password policy is configured in Palette with default values. Users can update the password compliance settings as per their requirements. When a spectrocloud_password_policy resource is destroyed, the password policy will revert to the Palette default settings. + +## Example Usage + +An example of managing an password policy in Palette. + +```hcl +resource "spectrocloud_password_policy" "policy_regex" { + # password_regex = "*" + password_expiry_days = 123 + first_reminder_days = 5 + min_digits = 1 + min_lowercase_letters = 12 + min_password_length = 12 + min_special_characters = 1 + min_uppercase_letters = 1 +} +``` + +{{ .SchemaMarkdown | trimspace }} \ No newline at end of file