diff --git a/vault/okta.go b/vault/okta.go new file mode 100644 index 000000000..f8da17713 --- /dev/null +++ b/vault/okta.go @@ -0,0 +1,154 @@ +package vault + +import ( + "fmt" + "github.com/hashicorp/vault/api" + "strings" +) + +type oktaUser struct { + Username string + Groups []string + Policies []string +} + +type oktaGroup struct { + Name string + Policies []string +} + +func isOktaUserPresent(client *api.Client, path, username string) (bool, error) { + secret, err := client.Logical().Read(oktaUserEndpoint(path, username)) + if err != nil { + return false, err + } + + return secret != nil, err +} + +func listOktaUsers(client *api.Client, path string) ([]string, error) { + secret, err := client.Logical().List(oktaUserEndpoint(path, "")) + if err != nil { + return []string{}, err + } + + if secret == nil || secret.Data == nil { + return []string{}, nil + } + + if v, ok := secret.Data["keys"]; ok { + return toStringArray(v.([]interface{})), nil + } + + return []string{}, nil +} + +func readOktaUser(client *api.Client, path string, username string) (*oktaUser, error) { + secret, err := client.Logical().Read(oktaUserEndpoint(path, username)) + + if err != nil { + return nil, err + } + + return &oktaUser{ + Username: username, + Groups: toStringArray(secret.Data["groups"].([]interface{})), + Policies: toStringArray(secret.Data["policies"].([]interface{})), + }, nil +} + +func updateOktaUser(client *api.Client, path string, user oktaUser) error { + _, err := client.Logical().Write(oktaUserEndpoint(path, user.Username), map[string]interface{}{ + "groups": strings.Join(user.Groups, ","), + "policies": strings.Join(user.Policies, ","), + }) + + return err +} + +func deleteOktaUser(client *api.Client, path, username string) error { + _, err := client.Logical().Delete(oktaUserEndpoint(path, username)) + return err +} + +func isOktaAuthBackendPresent(client *api.Client, path string) (bool, error) { + auths, err := client.Sys().ListAuth() + if err != nil { + return false, fmt.Errorf("error reading from Vault: %s", err) + } + + configuredPath := path + "/" + + for authBackendPath, auth := range auths { + + if auth.Type == "okta" && authBackendPath == configuredPath { + return true, nil + } + } + + return false, nil +} + +func isOktaGroupPresent(client *api.Client, path, name string) (bool, error) { + secret, err := client.Logical().Read(oktaGroupEndpoint(path, name)) + if err != nil { + return false, err + } + + return secret != nil, err +} + +func listOktaGroups(client *api.Client, path string) ([]string, error) { + secret, err := client.Logical().List(oktaGroupEndpoint(path, "")) + if err != nil { + return []string{}, err + } + + if secret == nil || secret.Data == nil { + return []string{}, nil + } + + if v, ok := secret.Data["keys"]; ok { + return toStringArray(v.([]interface{})), nil + } + + return []string{}, nil +} + +func readOktaGroup(client *api.Client, path string, name string) (*oktaGroup, error) { + secret, err := client.Logical().Read(oktaGroupEndpoint(path, name)) + + if err != nil { + return nil, err + } + + return &oktaGroup{ + Name: name, + Policies: toStringArray(secret.Data["policies"].([]interface{})), + }, nil +} + +func updateOktaGroup(client *api.Client, path string, group oktaGroup) error { + _, err := client.Logical().Write(oktaGroupEndpoint(path, group.Name), map[string]interface{}{ + "policies": strings.Join(group.Policies, ","), + }) + + return err +} + +func deleteOktaGroup(client *api.Client, path, name string) error { + _, err := client.Logical().Delete(oktaGroupEndpoint(path, name)) + return err +} + +func oktaConfigEndpoint(path string) string { + return fmt.Sprintf("/auth/%s/config", path) +} + +func oktaUserEndpoint(path, username string) string { + return fmt.Sprintf("/auth/%s/users/%s", path, username) +} + +func oktaGroupEndpoint(path, groupName string) string { + return fmt.Sprintf("/auth/%s/groups/%s", path, groupName) +} diff --git a/vault/provider.go b/vault/provider.go index f1abae229..9a3f3d75f 100644 --- a/vault/provider.go +++ b/vault/provider.go @@ -96,6 +96,9 @@ func Provider() terraform.ResourceProvider { "vault_aws_auth_backend_sts_role": awsAuthBackendSTSRoleResource(), "vault_aws_secret_backend": awsSecretBackendResource(), "vault_aws_secret_backend_role": awsSecretBackendRoleResource(), + "vault_okta_auth_backend": oktaAuthBackendResource(), + "vault_okta_auth_backend_user": oktaAuthBackendUserResource(), + "vault_okta_auth_backend_group": oktaAuthBackendGroupResource(), "vault_generic_secret": genericSecretResource(), "vault_policy": policyResource(), "vault_mount": mountResource(), diff --git a/vault/resource_okta_auth_backend.go b/vault/resource_okta_auth_backend.go new file mode 100644 index 000000000..d231ca55a --- /dev/null +++ b/vault/resource_okta_auth_backend.go @@ -0,0 +1,473 @@ +package vault + +import ( + "errors" + "fmt" + "log" + "strings" + + "github.com/hashicorp/terraform/helper/hashcode" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/vault/api" +) + +var oktaAuthType = "okta" + +func oktaAuthBackendResource() *schema.Resource { + return &schema.Resource{ + Create: oktaAuthBackendWrite, + Delete: oktaAuthBackendDelete, + Read: oktaAuthBackendRead, + Update: oktaAuthBackendUpdate, + + Schema: map[string]*schema.Schema{ + + "path": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "path to mount the backend", + Default: oktaAuthType, + ValidateFunc: func(v interface{}, k string) (ws []string, errs []error) { + value := v.(string) + if strings.HasSuffix(value, "/") { + errs = append(errs, errors.New("cannot write to a path ending in '/'")) + } + return + }, + }, + + "description": { + Type: schema.TypeString, + Required: false, + ForceNew: true, + Optional: true, + Description: "The description of the auth backend", + }, + + "organization": { + Type: schema.TypeString, + Required: true, + Optional: false, + Description: "The Okta organization. This will be the first part of the url https://XXX.okta.com.", + }, + + "token": { + Type: schema.TypeString, + Required: false, + Optional: true, + Description: "The Okta API token. This is required to query Okta for user group membership. If this is not supplied only locally configured groups will be enabled.", + }, + + "base_url": { + Type: schema.TypeString, + Required: false, + Optional: true, + Description: "The Okta url. Examples: oktapreview.com, okta.com (default)", + }, + + "ttl": { + Type: schema.TypeString, + Required: false, + Optional: true, + Description: "Duration after which authentication will be expired", + }, + + "max_ttl": { + Type: schema.TypeString, + Required: false, + Optional: true, + Description: "Maximum duration after which authentication will be expired", + }, + + "group": { + Type: schema.TypeSet, + Required: false, + Optional: true, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "group_name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the Okta group", + ValidateFunc: func(v interface{}, k string) (ws []string, errs []error) { + value := v.(string) + // No comma as it'll become part of a comma separate list + if strings.Contains(value, ",") || strings.Contains(value, "/") { + errs = append(errs, errors.New("group cannot contain ',' or '/'")) + } + return + }, + }, + + "policies": { + Type: schema.TypeSet, + Required: true, + Description: "Policies to associate with this group", + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: func(v interface{}, k string) (ws []string, errs []error) { + value := v.(string) + // No comma as it'll become part of a comma separate list + if strings.Contains(value, ",") { + errs = append(errs, errors.New("policy cannot contain ','")) + } + return + }, + }, + Set: schema.HashString, + }, + }, + }, + Set: resourceOktaGroupHash, + }, + + "user": { + Type: schema.TypeSet, + Required: false, + Optional: true, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "groups": { + Type: schema.TypeSet, + Required: true, + Description: "Groups within the Okta auth backend to associate with this user", + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: func(v interface{}, k string) (ws []string, errs []error) { + value := v.(string) + // No comma as it'll become part of a comma separate list + if strings.Contains(value, ",") || strings.Contains(value, "/") { + errs = append(errs, errors.New("group cannot contain ',' or '/'")) + } + return + }, + }, + Set: schema.HashString, + }, + + "username": { + Type: schema.TypeString, + Required: true, + Description: "Name of the user within Okta", + ValidateFunc: func(v interface{}, k string) (ws []string, errs []error) { + value := v.(string) + if strings.Contains(value, "/") { + errs = append(errs, errors.New("user cannot contain '/'")) + } + return + }, + }, + + "policies": { + Type: schema.TypeSet, + Required: false, + Optional: true, + Description: "Policies to associate with this user", + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: func(v interface{}, k string) (ws []string, errs []error) { + value := v.(string) + // No comma as it'll become part of a comma separate list + if strings.Contains(value, ",") { + errs = append(errs, errors.New("policy cannot contain ','")) + } + return + }, + }, + Set: schema.HashString, + }, + }, + }, + Set: resourceOktaUserHash, + }, + }, + } +} + +func oktaAuthBackendWrite(d *schema.ResourceData, meta interface{}) error { + client := meta.(*api.Client) + + authType := oktaAuthType + desc := d.Get("description").(string) + path := d.Get("path").(string) + + log.Printf("[DEBUG] Writing auth %s to Vault", authType) + + err := client.Sys().EnableAuth(path, authType, desc) + + if err != nil { + return fmt.Errorf("error writing to Vault: %s", err) + } + + d.SetId(path) + + return oktaAuthBackendUpdate(d, meta) +} + +func oktaAuthBackendDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*api.Client) + + path := d.Id() + + log.Printf("[DEBUG] Deleting auth %s from Vault", path) + + err := client.Sys().DisableAuth(path) + + if err != nil { + return fmt.Errorf("error disabling auth from Vault: %s", err) + } + + return nil +} + +func oktaAuthBackendRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*api.Client) + + path := d.Id() + log.Printf("[DEBUG] Reading auth %s from Vault", path) + + present, err := isOktaAuthBackendPresent(client, path) + + if err != nil { + return fmt.Errorf("unable to check auth backends in Vault for path %s: %s", path, err) + } + + if !present { + // If we fell out here then we didn't find our Auth in the list. + d.SetId("") + return nil + } + + log.Printf("[DEBUG] Reading groups for mount %s from Vault", path) + groups, err := oktaReadAllGroups(client, path) + if err != nil { + return err + } + if err := d.Set("group", groups); err != nil { + return err + } + + log.Printf("[DEBUG] Reading users for mount %s from Vault", path) + users, err := oktaReadAllUsers(client, path) + if err != nil { + return err + } + if err := d.Set("user", users); err != nil { + return err + } + + return nil + +} + +func oktaAuthBackendUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*api.Client) + + path := d.Id() + log.Printf("[DEBUG] Updating auth %s in Vault", path) + + configuration := map[string]interface{}{ + "base_url": d.Get("base_url"), + "organization": d.Get("organization"), + "token": d.Get("token"), + } + + if ttl, ok := d.GetOk("ttl"); ok { + configuration["ttl"] = ttl + } + + if maxTtl, ok := d.GetOk("max_ttl"); ok { + configuration["max_ttl"] = maxTtl + } + + _, err := client.Logical().Write(oktaConfigEndpoint(path), configuration) + if err != nil { + return fmt.Errorf("error updating configuration to Vault for path %s: %s", path, err) + } + + if d.HasChange("group") { + oldValue, newValue := d.GetChange("group") + + err = oktaAuthUpdateGroups(d, client, path, oldValue, newValue) + if err != nil { + return err + } + } + + if d.HasChange("user") { + oldValue, newValue := d.GetChange("user") + + err = oktaAuthUpdateUsers(d, client, path, oldValue, newValue) + if err != nil { + return err + } + } + + return oktaAuthBackendRead(d, meta) +} + +func oktaReadAllGroups(client *api.Client, path string) (*schema.Set, error) { + groupNames, err := listOktaGroups(client, path) + if err != nil { + return nil, fmt.Errorf("unable to list groups from %s in Vault: %s", path, err) + } + + groups := &schema.Set{F: resourceOktaGroupHash} + for _, groupName := range groupNames { + group, err := readOktaGroup(client, path, groupName) + if err != nil { + return nil, fmt.Errorf("unable to read group %s from %s in Vault: %s", path, groupName, err) + } + + policies := &schema.Set{F: schema.HashString} + for _, v := range group.Policies { + policies.Add(v) + } + + m := make(map[string]interface{}) + m["policies"] = policies + m["group_name"] = group.Name + + groups.Add(m) + } + + return groups, nil +} + +func oktaReadAllUsers(client *api.Client, path string) (*schema.Set, error) { + userNames, err := listOktaUsers(client, path) + if err != nil { + return nil, fmt.Errorf("unable to list groups from %s in Vault: %s", path, err) + } + + users := &schema.Set{F: resourceOktaUserHash} + for _, userName := range userNames { + user, err := readOktaUser(client, path, userName) + if err != nil { + return nil, fmt.Errorf("unable to read user %s from %s in Vault: %s", path, userName, err) + } + + groups := &schema.Set{F: schema.HashString} + for _, v := range user.Groups { + groups.Add(v) + } + + policies := &schema.Set{F: schema.HashString} + for _, v := range user.Policies { + policies.Add(v) + } + + m := make(map[string]interface{}) + m["policies"] = policies + m["groups"] = groups + m["username"] = user.Username + + users.Add(m) + } + + return users, nil +} + +func oktaAuthUpdateGroups(d *schema.ResourceData, client *api.Client, path string, oldValue, newValue interface{}) error { + + groupsToDelete := oldValue.(*schema.Set).Difference(newValue.(*schema.Set)) + newGroups := newValue.(*schema.Set).Difference(oldValue.(*schema.Set)) + + for _, group := range groupsToDelete.List() { + groupName := group.(map[string]interface{})["group_name"].(string) + log.Printf("[DEBUG] Removing Okta group %s from Vault", groupName) + if err := deleteOktaGroup(client, path, groupName); err != nil { + return fmt.Errorf("error removing group %s to Vault for path %s: %s", groupName, path, err) + } + } + + groups := oldValue.(*schema.Set).Intersection(newValue.(*schema.Set)) + d.Set("group", groups) + + for _, v := range newGroups.List() { + groupMapping := v.(map[string]interface{}) + groupName := groupMapping["group_name"].(string) + + log.Printf("[DEBUG] Adding Okta group %s to Vault", groupName) + + group := oktaGroup{ + Name: groupName, + Policies: toStringArray(groupMapping["policies"].(*schema.Set).List()), + } + + if err := updateOktaGroup(client, path, group); err != nil { + return fmt.Errorf("error updating group %s mapping to Vault for path %s: %s", group.Name, path, err) + } + + groups.Add(v) + d.Set("group", groups) + } + + return nil +} + +func oktaAuthUpdateUsers(d *schema.ResourceData, client *api.Client, path string, oldValue, newValue interface{}) error { + usersToDelete := oldValue.(*schema.Set).Difference(newValue.(*schema.Set)) + newUsers := newValue.(*schema.Set).Difference(oldValue.(*schema.Set)) + + for _, user := range usersToDelete.List() { + userName := user.(map[string]interface{})["username"].(string) + log.Printf("[DEBUG] Removing Okta user %s from Vault", userName) + if err := deleteOktaUser(client, path, userName); err != nil { + return fmt.Errorf("error removing user %s mapping to Vault for path %s: %s", userName, path, err) + } + } + + users := oldValue.(*schema.Set).Intersection(newValue.(*schema.Set)) + d.Set("user", users) + + for _, v := range newUsers.List() { + userMapping := v.(map[string]interface{}) + userName := userMapping["username"].(string) + + log.Printf("[DEBUG] Adding Okta user %s to Vault", userName) + + user := oktaUser{ + Username: userName, + Policies: toStringArray(userMapping["policies"].(*schema.Set).List()), + Groups: toStringArray(userMapping["groups"].(*schema.Set).List()), + } + + if err := updateOktaUser(client, path, user); err != nil { + return fmt.Errorf("error updating user %s mapping to Vault for path %s: %s", user.Username, path, err) + } + + users.Add(v) + d.Set("user", users) + + } + + return nil +} + +func resourceOktaGroupHash(v interface{}) int { + m, castOk := v.(map[string]interface{}) + if !castOk { + return 0 + } + if v, ok := m["group_name"]; ok { + return hashcode.String(v.(string)) + } + + return 0 +} + +func resourceOktaUserHash(v interface{}) int { + m, castOk := v.(map[string]interface{}) + if !castOk { + return 0 + } + if v, ok := m["username"]; ok { + return hashcode.String(v.(string)) + } + + return 0 +} diff --git a/vault/resource_okta_auth_backend_group.go b/vault/resource_okta_auth_backend_group.go new file mode 100644 index 000000000..76f25d51c --- /dev/null +++ b/vault/resource_okta_auth_backend_group.go @@ -0,0 +1,138 @@ +package vault + +import ( + "errors" + "fmt" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/vault/api" + "log" + "strings" +) + +func oktaAuthBackendGroupResource() *schema.Resource { + return &schema.Resource{ + Create: oktaAuthBackendGroupWrite, + Read: oktaAuthBackendGroupRead, + Update: oktaAuthBackendGroupWrite, + Delete: oktaAuthBackendGroupDelete, + + Schema: map[string]*schema.Schema{ + "path": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Path to the Okta auth backend", + }, + + "group_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the Okta group", + ValidateFunc: func(v interface{}, k string) (ws []string, errs []error) { + value := v.(string) + // No comma as it'll become part of a comma separate list + if strings.Contains(value, ",") || strings.Contains(value, "/") { + errs = append(errs, errors.New("group name cannot contain ',' or '/'")) + } + return + }, + }, + + "policies": { + Type: schema.TypeSet, + Required: false, + Optional: true, + Description: "Policies to associate with this group", + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: func(v interface{}, k string) (ws []string, errs []error) { + value := v.(string) + // No comma as it'll become part of a comma separate list + if strings.Contains(value, ",") { + errs = append(errs, errors.New("policy cannot contain ','")) + } + return + }, + }, + Set: schema.HashString, + }, + }, + } +} + +func oktaAuthBackendGroupWrite(d *schema.ResourceData, meta interface{}) error { + client := meta.(*api.Client) + + path := d.Get("path").(string) + groupName := d.Get("group_name").(string) + + log.Printf("[DEBUG] Writing group %s to Okta auth backend %s", groupName, path) + + var policiesString []string + if policies, ok := d.GetOk("policies"); ok { + policiesString = toStringArray(policies.(*schema.Set).List()) + } else { + policiesString = []string{} + } + + group := oktaGroup{ + Name: groupName, + Policies: policiesString, + } + if err := updateOktaGroup(client, path, group); err != nil { + return fmt.Errorf("unable to write group %s to Vault: %s", groupName, err) + } + + d.SetId(fmt.Sprintf("%s/%s", path, group)) + + return oktaAuthBackendGroupRead(d, meta) +} + +func oktaAuthBackendGroupRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*api.Client) + + path := d.Get("path").(string) + name := d.Get("group_name").(string) + + log.Printf("[DEBUG] Reading group %s from Okta auth backend %s", name, path) + + present, err := isOktaGroupPresent(client, path, name) + + if err != nil { + return fmt.Errorf("unable to read group %s from Vault: %s", name, err) + } + + if !present { + // Group not found, so remove this resource + d.SetId("") + return nil + } + + group, err := readOktaGroup(client, path, name) + + if err != nil { + return fmt.Errorf("unable to update group %s from Vault: %s", name, err) + } + + d.Set("policies", group.Policies) + + return nil +} + +func oktaAuthBackendGroupDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*api.Client) + + path := d.Get("path").(string) + group := d.Get("group_name").(string) + + log.Printf("[DEBUG] Deleting group %s from Okta auth backend %s", group, path) + + if err := deleteOktaGroup(client, path, group); err != nil { + return fmt.Errorf("unable to delete group %s from Vault: %s", group, err) + } + + d.SetId("") + + return nil +} diff --git a/vault/resource_okta_auth_backend_group_test.go b/vault/resource_okta_auth_backend_group_test.go new file mode 100644 index 000000000..29244f057 --- /dev/null +++ b/vault/resource_okta_auth_backend_group_test.go @@ -0,0 +1,76 @@ +package vault + +import ( + "fmt" + "github.com/hashicorp/terraform/helper/acctest" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" + "github.com/hashicorp/vault/api" + "strconv" + "testing" +) + +// This is light on testing as most of the code is covered by `resource_okta_auth_backend_test.go` +func TestOktaAuthBackendGroup(t *testing.T) { + path := "okta-" + strconv.Itoa(acctest.RandInt()) + + resource.Test(t, resource.TestCase{ + Providers: testProviders, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testOktaAuthBackendGroup_Destroyed(path, "foo"), + Steps: []resource.TestStep{ + { + Config: initialOktaAuthGroupConfig(path), + Check: resource.ComposeTestCheckFunc( + testOktaAuthBackendGroup_InitialCheck, + testOktaAuthBackend_GroupsCheck(path, "foo", []string{"one", "two", "default"}), + ), + }, + }, + }) +} + +func initialOktaAuthGroupConfig(path string) string { + return fmt.Sprintf(` +resource "vault_okta_auth_backend" "test" { + path = "%s" + organization = "dummy" +} + +resource "vault_okta_auth_backend_group" "test" { + path = "${vault_okta_auth_backend.test.path}" + group_name = "foo" + policies = ["one", "two", "default"] +} +`, path) +} + +func testOktaAuthBackendGroup_InitialCheck(s *terraform.State) error { + resourceState := s.Modules[0].Resources["vault_okta_auth_backend_group.test"] + if resourceState == nil { + return fmt.Errorf("resource not found in state") + } + + instanceState := resourceState.Primary + if instanceState == nil { + return fmt.Errorf("resource has no primary instance") + } + + return nil +} + +func testOktaAuthBackendGroup_Destroyed(path, groupName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testProvider.Meta().(*api.Client) + + group, err := client.Logical().Read(fmt.Sprintf("/auth/%s/groups/%s", path, groupName)) + if err != nil { + return fmt.Errorf("error reading back configuration: %s", err) + } + if group != nil { + return fmt.Errorf("okta group still exists") + } + + return nil + } +} diff --git a/vault/resource_okta_auth_backend_test.go b/vault/resource_okta_auth_backend_test.go new file mode 100644 index 000000000..e2c72689b --- /dev/null +++ b/vault/resource_okta_auth_backend_test.go @@ -0,0 +1,253 @@ +package vault + +import ( + "encoding/json" + "fmt" + "github.com/hashicorp/terraform/helper/acctest" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" + "github.com/hashicorp/vault/api" + "strconv" + "testing" + "time" +) + +func TestOktaAuthBackend(t *testing.T) { + path := "okta-" + strconv.Itoa(acctest.RandInt()) + + resource.Test(t, resource.TestCase{ + Providers: testProviders, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testOktaAuthBackend_Destroyed(path), + Steps: []resource.TestStep{ + { + Config: initialOktaAuthConfig(path), + Check: resource.ComposeTestCheckFunc( + testOktaAuthBackend_InitialCheck, + testOktaAuthBackend_GroupsCheck(path, "dummy", []string{"one", "two", "default"}), + testOktaAuthBackend_UsersCheck(path, "foo", []string{"dummy"}, []string{""}), + ), + }, + { + Config: updatedOktaAuthConfig(path), + Check: resource.ComposeTestCheckFunc( + testOktaAuthBackend_GroupsCheck(path, "example", []string{"three", "four", "default"}), + testOktaAuthBackend_UsersCheck(path, "bar", []string{"example"}, []string{""}), + ), + }, + }, + }) +} + +func initialOktaAuthConfig(path string) string { + return fmt.Sprintf(` +resource "vault_okta_auth_backend" "test" { + description = "Testing the Terraform okta auth backend" + organization = "example" + path = "%s" + token = "this must be kept secret" + ttl = "1h" + group { + group_name = "dummy" + policies = ["one", "two", "default"] + } + user { + username = "foo" + groups = ["dummy"] + } +} +`, path) +} + +func testOktaAuthBackend_InitialCheck(s *terraform.State) error { + resourceState := s.Modules[0].Resources["vault_okta_auth_backend.test"] + if resourceState == nil { + return fmt.Errorf("resource not found in state") + } + + instanceState := resourceState.Primary + if instanceState == nil { + return fmt.Errorf("resource has no primary instance") + } + + path := instanceState.ID + + if path != instanceState.Attributes["path"] { + return fmt.Errorf("id doesn't match path") + } + + client := testProvider.Meta().(*api.Client) + + authMounts, err := client.Sys().ListAuth() + if err != nil { + return err + } + + authMount := authMounts[path+"/"] + + if authMount == nil { + return fmt.Errorf("auth mount %s not present", path) + } + + if "okta" != authMount.Type { + return fmt.Errorf("incorrect mount type: %s", authMount.Type) + } + + if "Testing the Terraform okta auth backend" != authMount.Description { + return fmt.Errorf("incorrect description: %s", authMount.Description) + } + + config, err := client.Logical().Read(fmt.Sprintf("/auth/%s/config", path)) + if err != nil { + return fmt.Errorf("error reading back configuration: %s", err) + } + + if "example" != config.Data["organization"] { + return fmt.Errorf("incorrect organization: %s", config.Data["organization"]) + } + + ttl, err := config.Data["ttl"].(json.Number).Int64() + if err != nil { + return err + } + if (time.Hour * 1).Nanoseconds() != ttl { + return fmt.Errorf("incorrect ttl: %s", config.Data["ttl"]) + } + + return nil +} + +func testOktaAuthBackend_GroupsCheck(path, groupName string, expectedPolicies []string) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testProvider.Meta().(*api.Client) + + groupList, err := client.Logical().List(fmt.Sprintf("/auth/%s/groups", path)) + if err != nil { + return fmt.Errorf("error reading back group configuration: %s", err) + } + + if len(groupList.Data["keys"].([]interface{})) != 1 { + return fmt.Errorf("unexpected groups present: %v", groupList.Data) + } + + dummyGroup, err := client.Logical().Read(fmt.Sprintf("/auth/%s/groups/%s", path, groupName)) + if err != nil { + return fmt.Errorf("error reading back configuration: %s", err) + } + + var missing []interface{} + + actual := toStringArray(dummyGroup.Data["policies"].([]interface{})) + EXPECTED: + for _, i := range expectedPolicies { + for _, j := range actual { + if i == j { + continue EXPECTED + } + } + + missing = append(missing, i) + } + + if len(missing) != 0 { + return fmt.Errorf("group policies incorrect; expected %[1]v, actual %[2]v (types: %[1]T, %[2]T)", expectedPolicies, actual) + } + + return nil + } + +} + +func testOktaAuthBackend_UsersCheck(path, userName string, expectedGroups, expectedPolicies []string) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testProvider.Meta().(*api.Client) + + userList, err := client.Logical().List(fmt.Sprintf("/auth/%s/users", path)) + if err != nil { + return fmt.Errorf("error reading back configuration: %s", err) + } + + if len(userList.Data["keys"].([]interface{})) != 1 { + return fmt.Errorf("unexpected users present: %v", userList.Data) + } + + user, err := client.Logical().Read(fmt.Sprintf("/auth/%s/users/%s", path, userName)) + if err != nil { + return fmt.Errorf("error reading back configuration: %s", err) + } + + var missing []interface{} + + actual := toStringArray(user.Data["policies"].([]interface{})) + EXPECTED_POLICIES: + for _, i := range expectedPolicies { + for _, j := range actual { + if i == j { + continue EXPECTED_POLICIES + } + } + + missing = append(missing, i) + } + + if len(missing) != 0 { + return fmt.Errorf("user policies incorrect; expected %[1]v, actual %[2]v (types: %[1]T, %[2]T)", expectedPolicies, actual) + } + + actual = toStringArray(user.Data["groups"].([]interface{})) + EXPECTED_GROUPS: + for _, i := range expectedGroups { + for _, j := range actual { + if i == j { + continue EXPECTED_GROUPS + } + } + + missing = append(missing, i) + } + + if len(missing) != 0 { + return fmt.Errorf("user groups incorrect; expected %[1]v, actual %[2]v (types: %[1]T, %[2]T)", expectedGroups, actual) + } + + return nil + } + +} + +func updatedOktaAuthConfig(path string) string { + return fmt.Sprintf(` +resource "vault_okta_auth_backend" "test" { + description = "Testing the Terraform okta auth backend" + organization = "example" + path = "%s" + token = "this must be kept secret" + group { + group_name = "example" + policies = ["three", "four", "default"] + } + user { + username = "bar" + groups = ["example"] + } +} +`, path) +} + +func testOktaAuthBackend_Destroyed(path string) resource.TestCheckFunc { + return func(s *terraform.State) error { + + client := testProvider.Meta().(*api.Client) + + authMounts, err := client.Sys().ListAuth() + if err != nil { + return err + } + + if _, ok := authMounts[fmt.Sprintf("%s/", path)]; ok { + return fmt.Errorf("auth mount not destroyed") + } + + return nil + } +} diff --git a/vault/resource_okta_auth_backend_user.go b/vault/resource_okta_auth_backend_user.go new file mode 100644 index 000000000..63ef52955 --- /dev/null +++ b/vault/resource_okta_auth_backend_user.go @@ -0,0 +1,157 @@ +package vault + +import ( + "errors" + "fmt" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/vault/api" + "log" + "strings" +) + +func oktaAuthBackendUserResource() *schema.Resource { + return &schema.Resource{ + Create: oktaAuthBackendUserWrite, + Read: oktaAuthBackendUserRead, + Update: oktaAuthBackendUserWrite, + Delete: oktaAuthBackendUserDelete, + + Schema: map[string]*schema.Schema{ + "path": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Path to the Okta auth backend", + }, + + "username": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the user within Okta", + }, + + "groups": { + Type: schema.TypeSet, + Required: false, + Optional: true, + Description: "Groups within the Okta auth backend to associate with this user", + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: func(v interface{}, k string) (ws []string, errs []error) { + value := v.(string) + // No comma as it'll become part of a comma separate list + if strings.Contains(value, ",") || strings.Contains(value, "/") { + errs = append(errs, errors.New("group cannot contain ',' or '/'")) + } + return + }, + }, + Set: schema.HashString, + }, + + "policies": { + Type: schema.TypeSet, + Required: false, + Optional: true, + Description: "Policies to associate with this user", + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: func(v interface{}, k string) (ws []string, errs []error) { + value := v.(string) + // No comma as it'll become part of a comma separate list + if strings.Contains(value, ",") { + errs = append(errs, errors.New("policy cannot contain ','")) + } + return + }, + }, + Set: schema.HashString, + }, + }, + } +} + +func oktaAuthBackendUserWrite(d *schema.ResourceData, meta interface{}) error { + client := meta.(*api.Client) + + username := d.Get("username").(string) + path := d.Get("path").(string) + + log.Printf("[DEBUG] Writing user %s to Okta auth backend %s", username, path) + + var groupsString []string + if groups, ok := d.GetOk("groups"); ok { + groupsString = toStringArray(groups.(*schema.Set).List()) + } else { + groupsString = []string{} + } + + var policiesString []string + if policies, ok := d.GetOk("policies"); ok { + policiesString = toStringArray(policies.(*schema.Set).List()) + } else { + policiesString = []string{} + } + + user := oktaUser{ + Username: username, + Groups: groupsString, + Policies: policiesString, + } + if err := updateOktaUser(client, path, user); err != nil { + return fmt.Errorf("unable to update user %s in Vault: %s", username, err) + } + + d.SetId(fmt.Sprintf("%s/%s", path, username)) + + return oktaAuthBackendUserRead(d, meta) +} + +func oktaAuthBackendUserRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*api.Client) + + path := d.Get("path").(string) + username := d.Get("username").(string) + + log.Printf("[DEBUG] Reading user %s from Okta auth backend %s", username, path) + + present, err := isOktaUserPresent(client, path, username) + + if err != nil { + return fmt.Errorf("unable to read user %s in Vault: %s", username, err) + } + + if !present { + // User not found, so remove this resource + d.SetId("") + return nil + } + + user, err := readOktaUser(client, path, username) + if err != nil { + return fmt.Errorf("unable to update user %s from Vault: %s", username, err) + } + + d.Set("groups", user.Groups) + d.Set("policies", user.Policies) + + return nil +} + +func oktaAuthBackendUserDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*api.Client) + + path := d.Get("path").(string) + username := d.Get("username").(string) + + log.Printf("[DEBUG] Deleting user %s from Okta auth backend %s", username, path) + + if err := deleteOktaUser(client, path, username); err != nil { + return fmt.Errorf("unable to delete user %s from Vault: %s", username, path) + } + + d.SetId("") + + return nil +} diff --git a/vault/resource_okta_auth_backend_user_test.go b/vault/resource_okta_auth_backend_user_test.go new file mode 100644 index 000000000..a1b41741b --- /dev/null +++ b/vault/resource_okta_auth_backend_user_test.go @@ -0,0 +1,77 @@ +package vault + +import ( + "fmt" + "github.com/hashicorp/terraform/helper/acctest" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" + "github.com/hashicorp/vault/api" + "strconv" + "testing" +) + +// This is light on testing as most of the code is covered by `resource_okta_auth_backend_test.go` +func TestOktaAuthBackendUser(t *testing.T) { + path := "okta-" + strconv.Itoa(acctest.RandInt()) + + resource.Test(t, resource.TestCase{ + Providers: testProviders, + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testOktaAuthBackendUser_Destroyed(path, "user_test"), + Steps: []resource.TestStep{ + { + Config: initialOktaAuthUserConfig(path), + Check: resource.ComposeTestCheckFunc( + testOktaAuthBackendUser_InitialCheck, + testOktaAuthBackend_UsersCheck(path, "user_test", []string{"one", "two"}, []string{"three"}), + ), + }, + }, + }) +} + +func initialOktaAuthUserConfig(path string) string { + return fmt.Sprintf(` +resource "vault_okta_auth_backend" "test" { + path = "%s" + organization = "dummy" +} + +resource "vault_okta_auth_backend_user" "test" { + path = "${vault_okta_auth_backend.test.path}" + username = "user_test" + groups = ["one", "two"] + policies = ["three"] +} +`, path) +} + +func testOktaAuthBackendUser_InitialCheck(s *terraform.State) error { + resourceState := s.Modules[0].Resources["vault_okta_auth_backend_user.test"] + if resourceState == nil { + return fmt.Errorf("resource not found in state") + } + + instanceState := resourceState.Primary + if instanceState == nil { + return fmt.Errorf("resource has no primary instance") + } + + return nil +} + +func testOktaAuthBackendUser_Destroyed(path, userName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testProvider.Meta().(*api.Client) + + group, err := client.Logical().Read(fmt.Sprintf("/auth/%s/users/%s", path, userName)) + if err != nil { + return fmt.Errorf("error reading back configuration: %s", err) + } + if group != nil { + return fmt.Errorf("okta user still exists") + } + + return nil + } +} diff --git a/vault/util.go b/vault/util.go index 48c750a58..85b85a461 100644 --- a/vault/util.go +++ b/vault/util.go @@ -22,3 +22,13 @@ func jsonDiffSuppress(k, old, new string, d *schema.ResourceData) bool { } return reflect.DeepEqual(oldJSON, newJSON) } + +func toStringArray(input []interface{}) []string { + output := make([]string, len(input)) + + for i, item := range input { + output[i] = item.(string) + } + + return output +} diff --git a/website/docs/r/okta_auth_backend.html.md b/website/docs/r/okta_auth_backend.html.md new file mode 100644 index 000000000..886dde18d --- /dev/null +++ b/website/docs/r/okta_auth_backend.html.md @@ -0,0 +1,75 @@ +--- +layout: "vault" +page_title: "Vault: vault_auth_backend resource" +sidebar_current: "docs-vault-resource-okta-auth-backend" +description: |- + Managing Okta auth backends in Vault +--- + +# vault\_okta\_auth\_backend + +Provides a resource for managing an +[Okta auth backend within Vault](https://www.vaultproject.io/docs/auth/okta.html). + +## Example Usage + +```hcl +resource "vault_okta_auth_backend" "example" { + description = "Demonstration of the Terraform Okta auth backend" + organization = "example" + token = "something that should be kept secret" + group { + group_name = "foo" + policies = ["one", "two"] + } + user { + username = "bar" + groups = ["foo"] + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `path` - (Required) Path to mount the Okta auth backend + +* `description` - (Optional) The description of the auth backend + +* `organization` - (Required) The Okta organization. This will be the first part of the url `https://XXX.okta.com` + +* `token` - (Optional) The Okta API token. This is required to query Okta for user group membership. +If this is not supplied only locally configured groups will be enabled. + +* `base_url` - (Optional) The Okta url. Examples: oktapreview.com, okta.com + +* `ttl` - (Optional) Duration after which authentication will be expired. +[See the documentation for info on valid duration formats](https://golang.org/pkg/time/#ParseDuration). + +* `max_ttl` - (Optional) Maximum duration after which authentication will be expired +[See the documentation for info on valid duration formats](https://golang.org/pkg/time/#ParseDuration). + +* `group` - (Optional) Associate Okta groups with policies within Vault. +[See below for more details](#okta-group). + +* `user` - (Optional) Associate Okta users with groups or policies within Vault. +[See below for more details](#okta-user). + +### Okta Group + +* `group_name` - (Required) Name of the group within the Okta + +* `policies` - (Optional) Vault policies to associate with this group + +### Okta User + +* `username` - (Required Optional) Name of the user within Okta + +* `groups` - (Optional) List of Okta groups to associate with this user + +* `policies` - (Optional) List of Vault policies to associate with this user + +## Attributes Reference + +No additional attributes are exposed by this resource. diff --git a/website/docs/r/okta_auth_backend_group.html.md b/website/docs/r/okta_auth_backend_group.html.md new file mode 100644 index 000000000..22c491689 --- /dev/null +++ b/website/docs/r/okta_auth_backend_group.html.md @@ -0,0 +1,41 @@ +--- +layout: "vault" +page_title: "Vault: vault_auth_backend_group resource" +sidebar_current: "docs-vault-resource-okta-auth-backend-group" +description: |- + Managing groups in an Okta auth backend in Vault +--- + +# vault\_okta\_auth\_backend\_group + +Provides a resource to create a group in an +[Okta auth backend within Vault](https://www.vaultproject.io/docs/auth/okta.html). + +## Example Usage + +```hcl +resource "vault_okta_auth_backend" "example" { + path = "group_okta" + organization = "dummy" +} + +resource "vault_okta_auth_backend_group" "foo" { + path = "${vault_okta_auth_backend.example.path}" + group_name = "foo" + policies = ["one", "two"] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `path` - (Required) The path where the Okta auth backend is mounted + +* `group_name` - (Required) Name of the group within the Okta + +* `policies` - (Optional) Vault policies to associate with this group + +## Attributes Reference + +No additional attributes are exposed by this resource. diff --git a/website/docs/r/okta_auth_backend_user.html.md b/website/docs/r/okta_auth_backend_user.html.md new file mode 100644 index 000000000..f57e54e46 --- /dev/null +++ b/website/docs/r/okta_auth_backend_user.html.md @@ -0,0 +1,43 @@ +--- +layout: "vault" +page_title: "Vault: vault_auth_backend_user resource" +sidebar_current: "docs-vault-resource-okta-auth-backend-user" +description: |- + Managing users in an Okta auth backend in Vault +--- + +# vault\_okta\_auth\_backend\_user + +Provides a resource to create a user in an +[Okta auth backend within Vault](https://www.vaultproject.io/docs/auth/okta.html). + +## Example Usage + +```hcl +resource "vault_okta_auth_backend" "example" { + path = "user_okta" + organization = "dummy" +} + +resource "vault_okta_auth_backend_user" "foo" { + path = "${vault_okta_auth_backend.example.path}" + username = "foo" + groups = ["one", "two"] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `path` - (Required) The path where the Okta auth backend is mounted + +* `username` - (Required Optional) Name of the user within Okta + +* `groups` - (Optional) List of Okta groups to associate with this user + +* `policies` - (Optional) List of Vault policies to associate with this user + +## Attributes Reference + +No additional attributes are exposed by this resource. diff --git a/website/vault.erb b/website/vault.erb index 9d12550f7..e697f074d 100644 --- a/website/vault.erb +++ b/website/vault.erb @@ -39,7 +39,7 @@ > vault_aws_auth_backend_client - + > vault_aws_auth_backend_sts_role @@ -52,6 +52,18 @@ vault_aws_secret_backend_role + > + vault_okta_auth_backend + + + > + vault_okta_auth_backend_user + + + > + vault_okta_auth_backend_group + + > vault_generic_secret