diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b6bb45..f9f855c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ -## Unreleased +## 0.22.0 -ENHANCEMENTS: +* **New Resource:** `auth0_organization` ([#458](https://github.com/alexkappa/terraform-provider-auth0/pull/458)) + +## 0.21.1 * resource/auth0_client: Documentation removal of `custom_login_page_preview` field [#386](https://github.com/alexkappa/terraform-provider-auth0/pull/386) * resource/auth0_client: Add `organization_usage` and `organization_require_behavior` parameters to `auth0_client` resource. ([#451](https://github.com/alexkappa/terraform-provider-auth0/pull/451)) @@ -8,6 +10,7 @@ ENHANCEMENTS: NOTES: * Bumped go-auth0 version to v5.17.0 [#398](https://github.com/alexkappa/terraform-provider-auth0/pull/398) +* Build darwin/arm64 binaries for Mac M1 silicon ([#421](https://github.com/alexkappa/terraform-provider-auth0/pull/421)) ## 0.21.0 diff --git a/auth0/internal/hash/hash.go b/auth0/internal/hash/hash.go new file mode 100644 index 00000000..36c3ee97 --- /dev/null +++ b/auth0/internal/hash/hash.go @@ -0,0 +1,21 @@ +package hash + +import ( + "github.com/hashicorp/terraform-plugin-sdk/helper/hashcode" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +// StringKey returns a schema.SchemaSetFunc able to hash a string value +// from map accessed by k. +func StringKey(k string) schema.SchemaSetFunc { + return func(v interface{}) int { + m, ok := v.(map[string]interface{}) + if !ok { + return 0 + } + if v, ok := m[k].(string); ok { + return hashcode.String(v) + } + return 0 + } +} diff --git a/auth0/internal/hash/hash_test.go b/auth0/internal/hash/hash_test.go new file mode 100644 index 00000000..c9ae95a8 --- /dev/null +++ b/auth0/internal/hash/hash_test.go @@ -0,0 +1,23 @@ +package hash + +import "testing" + +func TestStringKey(t *testing.T) { + + v := map[string]interface{}{ + "Foo": "Foo", + "Bar": "Bar", + } + + for key, expected := range map[string]int{ + "Foo": 3023971265, + "Bar": 1320340042, + } { + t.Run(key, func(t *testing.T) { + fn := StringKey(key) + if fn(v) != expected { + t.Errorf("expected %d to be %d", fn(v), expected) + } + }) + } +} diff --git a/auth0/provider.go b/auth0/provider.go index e881da68..d4fe7c96 100644 --- a/auth0/provider.go +++ b/auth0/provider.go @@ -63,6 +63,7 @@ func init() { "auth0_log_stream": newLogStream(), "auth0_branding": newBranding(), "auth0_guardian": newGuardian(), + "auth0_organization": newOrganization(), }, ConfigureFunc: Configure, } diff --git a/auth0/resource_auth0_email_template_test.go b/auth0/resource_auth0_email_template_test.go index 8b209590..e581f01c 100644 --- a/auth0/resource_auth0_email_template_test.go +++ b/auth0/resource_auth0_email_template_test.go @@ -32,7 +32,7 @@ func TestAccEmailTemplate(t *testing.T) { "auth0": Provider(), }, Steps: []resource.TestStep{ - resource.TestStep{ + { Config: testAccEmailTemplateConfig, Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("auth0_email_template.my_email_template", "template", "welcome_email"), diff --git a/auth0/resource_auth0_organization.go b/auth0/resource_auth0_organization.go new file mode 100644 index 00000000..21259260 --- /dev/null +++ b/auth0/resource_auth0_organization.go @@ -0,0 +1,239 @@ +package auth0 + +import ( + "fmt" + "log" + "net/http" + + "github.com/alexkappa/terraform-provider-auth0/auth0/internal/hash" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "gopkg.in/auth0.v5/management" +) + +func newOrganization() *schema.Resource { + return &schema.Resource{ + + Create: createOrganization, + Read: readOrganization, + Update: updateOrganization, + Delete: deleteOrganization, + + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of this organization", + }, + "display_name": { + Type: schema.TypeString, + Optional: true, + Description: "Friendly name of this organization", + }, + "branding": { + Type: schema.TypeList, + Optional: true, + Computed: true, + MaxItems: 1, + MinItems: 1, + Description: "Defines how to style the login pages", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "logo_url": { + Type: schema.TypeString, + Optional: true, + }, + "colors": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + }, + }, + }, + "metadata": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Metadata associated with the organization, Maximum of 10 metadata properties allowed", + }, + "connections": { + Type: schema.TypeSet, + Optional: true, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "connection_id": { + Type: schema.TypeString, + Required: true, + }, + "assign_membership_on_login": { + Type: schema.TypeBool, + Optional: true, + }, + }, + }, + Set: hash.StringKey("connection_id"), + }, + }, + } +} + +func createOrganization(d *schema.ResourceData, m interface{}) error { + o := expandOrganization(d) + api := m.(*management.Management) + if err := api.Organization.Create(o); err != nil { + return err + } + d.SetId(o.GetID()) + + d.Partial(true) + err := assignOrganizationConnections(d, m) + if err != nil { + return fmt.Errorf("failed assigning organization connections. %w", err) + } + d.Partial(false) + + return readOrganization(d, m) +} + +func assignOrganizationConnections(d *schema.ResourceData, m interface{}) (err error) { + + api := m.(*management.Management) + + add, rm := Diff(d, "connections") + + add.Elem(func(dd ResourceData) { + c := &management.OrganizationConnection{ + ConnectionID: String(dd, "connection_id"), + AssignMembershipOnLogin: Bool(dd, "assign_membership_on_login"), + } + log.Printf("[DEBUG] (+) auth0_organization.%s.connections.%s", d.Id(), c.GetConnectionID()) + err = api.Organization.AddConnection(d.Id(), c) + if err != nil { + return + } + }) + + rm.Elem(func(dd ResourceData) { + // Take connectionID before it changed (i.e. removed). Therefore we use + // GetChange() instead of the typical Get(). + connectionID, _ := dd.GetChange("connection_id") + log.Printf("[DEBUG] (-) auth0_organization.%s.connections.%s", d.Id(), connectionID.(string)) + err = api.Organization.DeleteConnection(d.Id(), connectionID.(string)) + if err != nil { + return + } + }) + + // Update existing connections if any mutable properties have changed. + Set(d, "connections", HasChange()).Elem(func(dd ResourceData) { + connectionID := dd.Get("connection_id").(string) + c := &management.OrganizationConnection{ + AssignMembershipOnLogin: Bool(dd, "assign_membership_on_login"), + } + log.Printf("[DEBUG] (~) auth0_organization.%s.connections.%s", d.Id(), connectionID) + err = api.Organization.UpdateConnection(d.Id(), connectionID, c) + if err != nil { + return + } + }) + + return nil +} + +func readOrganization(d *schema.ResourceData, m interface{}) error { + api := m.(*management.Management) + o, err := api.Organization.Read(d.Id()) + if err != nil { + if mErr, ok := err.(management.Error); ok { + if mErr.Status() == http.StatusNotFound { + d.SetId("") + return nil + } + } + return err + } + + d.SetId(o.GetID()) + d.Set("name", o.Name) + d.Set("display_name", o.DisplayName) + d.Set("branding", flattenOrganizationBranding(o.Branding)) + d.Set("metadata", o.Metadata) + + l, err := api.Organization.Connections(d.Id()) + if err != nil { + return err + } + + d.Set("connections", func() (v []interface{}) { + for _, connection := range l.OrganizationConnections { + v = append(v, &map[string]interface{}{ + "connection_id": connection.ConnectionID, + "assign_membership_on_login": connection.AssignMembershipOnLogin, + }) + } + return + }()) + + return nil +} + +func updateOrganization(d *schema.ResourceData, m interface{}) error { + o := expandOrganization(d) + api := m.(*management.Management) + err := api.Organization.Update(d.Id(), o) + if err != nil { + return err + } + + d.Partial(true) + err = assignOrganizationConnections(d, m) + if err != nil { + return fmt.Errorf("failed updating organization connections. %w", err) + } + d.Partial(false) + + return readOrganization(d, m) +} + +func deleteOrganization(d *schema.ResourceData, m interface{}) error { + api := m.(*management.Management) + err := api.Organization.Delete(d.Id()) + if err != nil { + if mErr, ok := err.(management.Error); ok { + if mErr.Status() == http.StatusNotFound { + d.SetId("") + return nil + } + } + } + return err +} + +func expandOrganization(d *schema.ResourceData) *management.Organization { + o := &management.Organization{ + Name: String(d, "name"), + DisplayName: String(d, "display_name"), + Metadata: Map(d, "metadata"), + } + List(d, "branding").Elem(func(d ResourceData) { + o.Branding = &management.OrganizationBranding{ + LogoURL: String(d, "logo_url"), + Colors: Map(d, "colors"), + } + }) + return o +} + +func flattenOrganizationBranding(b *management.OrganizationBranding) []interface{} { + m := make(map[string]interface{}) + if b != nil { + m["logo_url"] = b.LogoURL + m["colors"] = b.Colors + } + return []interface{}{m} +} diff --git a/auth0/resource_auth0_organization_test.go b/auth0/resource_auth0_organization_test.go new file mode 100644 index 00000000..856d2980 --- /dev/null +++ b/auth0/resource_auth0_organization_test.go @@ -0,0 +1,158 @@ +package auth0 + +import ( + "log" + "strings" + "testing" + + "github.com/alexkappa/terraform-provider-auth0/auth0/internal/random" + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/terraform" + "gopkg.in/auth0.v5/management" +) + +func init() { + resource.AddTestSweepers("auth0_organization", &resource.Sweeper{ + Name: "auth0_organization", + F: func(_ string) error { + api, err := Auth0() + if err != nil { + return err + } + var page int + for { + l, err := api.Organization.List(management.Page(page)) + if err != nil { + return err + } + for _, organization := range l.Organizations { + log.Printf("[DEBUG] ➝ %s", organization.GetName()) + if strings.Contains(organization.GetName(), "test") { + if e := api.Organization.Delete(organization.GetID()); e != nil { + multierror.Append(err, e) + } + log.Printf("[DEBUG] ✗ %s", organization.GetName()) + } + } + + if err != nil { + return err + } + if !l.HasNext() { + break + } + page++ + } + return nil + }, + }) +} + +func TestAccOrganization(t *testing.T) { + + rand := random.String(6) + + resource.Test(t, resource.TestCase{ + Providers: map[string]terraform.ResourceProvider{ + "auth0": Provider(), + }, + Steps: []resource.TestStep{ + { + Config: random.Template(testAccOrganizationCreate, rand), + Check: resource.ComposeTestCheckFunc( + random.TestCheckResourceAttr("auth0_organization.acme", "name", "test-{{.random}}", rand), + random.TestCheckResourceAttr("auth0_organization.acme", "display_name", "Acme Inc. {{.random}}", rand), + resource.TestCheckResourceAttr("auth0_organization.acme", "connections.#", "1"), + ), + }, + { + Config: random.Template(testAccOrganizationUpdate, rand), + Check: resource.ComposeTestCheckFunc( + random.TestCheckResourceAttr("auth0_organization.acme", "name", "test-{{.random}}", rand), + random.TestCheckResourceAttr("auth0_organization.acme", "display_name", "Acme Inc. {{.random}}", rand), + resource.TestCheckResourceAttr("auth0_organization.acme", "branding.#", "1"), + resource.TestCheckResourceAttr("auth0_organization.acme", "branding.0.logo_url", "https://acme.com/logo.svg"), + resource.TestCheckResourceAttr("auth0_organization.acme", "branding.0.colors.%", "2"), + resource.TestCheckResourceAttr("auth0_organization.acme", "branding.0.colors.primary", "#e3e2f0"), + resource.TestCheckResourceAttr("auth0_organization.acme", "branding.0.colors.page_background", "#e3e2ff"), + resource.TestCheckResourceAttr("auth0_organization.acme", "connections.#", "2"), + ), + }, + { + Config: random.Template(testAccOrganizationUpdateAgain, rand), + Check: resource.ComposeTestCheckFunc( + random.TestCheckResourceAttr("auth0_organization.acme", "name", "test-{{.random}}", rand), + random.TestCheckResourceAttr("auth0_organization.acme", "display_name", "Acme Inc. {{.random}}", rand), + resource.TestCheckResourceAttr("auth0_organization.acme", "connections.#", "1"), + ), + }, + }, + }) +} + +const testAccOrganizationAux = ` + +resource auth0_connection acme { + name = "Acceptance-Test-Connection-Acme-{{.random}}" + strategy = "auth0" +} + +resource auth0_connection acmeinc { + name = "Acceptance-Test-Connection-Acme-Inc-{{.random}}" + strategy = "auth0" +} +` + +const testAccOrganizationCreate = testAccOrganizationAux + ` + +resource auth0_organization acme { + name = "test-{{.random}}" + display_name = "Acme Inc. {{.random}}" + + connections { + connection_id = auth0_connection.acme.id + } +} +` + +const testAccOrganizationUpdate = testAccOrganizationAux + ` + +resource auth0_organization acme { + name = "test-{{.random}}" + display_name = "Acme Inc. {{.random}}" + branding { + logo_url = "https://acme.com/logo.svg" + colors = { + primary = "#e3e2f0" + page_background = "#e3e2ff" + } + } + connections { + connection_id = auth0_connection.acme.id + } + connections { + connection_id = auth0_connection.acmeinc.id + assign_membership_on_login = true + } +} +` + +const testAccOrganizationUpdateAgain = testAccOrganizationAux + ` + +resource auth0_organization acme { + name = "test-{{.random}}" + display_name = "Acme Inc. {{.random}}" + branding { + logo_url = "https://acme.com/logo.svg" + colors = { + primary = "#e3e2f0" + page_background = "#e3e2ff" + } + } + connections { + connection_id = auth0_connection.acmeinc.id + assign_membership_on_login = false + } +} +` diff --git a/auth0/resource_auth0_role.go b/auth0/resource_auth0_role.go index 5eb45b0c..c2642fba 100644 --- a/auth0/resource_auth0_role.go +++ b/auth0/resource_auth0_role.go @@ -161,7 +161,7 @@ func assignRolePermissions(d *schema.ResourceData, m interface{}) error { add, rm := Diff(d, "permissions") var addPermissions []*management.Permission - for _, addPermission := range add { + for _, addPermission := range add.List() { permission := addPermission.(map[string]interface{}) addPermissions = append(addPermissions, &management.Permission{ Name: auth0.String(permission["name"].(string)), @@ -170,7 +170,7 @@ func assignRolePermissions(d *schema.ResourceData, m interface{}) error { } var rmPermissions []*management.Permission - for _, rmPermission := range rm { + for _, rmPermission := range rm.List() { permission := rmPermission.(map[string]interface{}) rmPermissions = append(rmPermissions, &management.Permission{ Name: auth0.String(permission["name"].(string)), diff --git a/auth0/resource_auth0_rule_test.go b/auth0/resource_auth0_rule_test.go index 1660254d..c0be0ae9 100644 --- a/auth0/resource_auth0_rule_test.go +++ b/auth0/resource_auth0_rule_test.go @@ -18,7 +18,7 @@ func TestAccRule(t *testing.T) { "auth0": Provider(), }, Steps: []resource.TestStep{ - resource.TestStep{ + { Config: random.Template(testAccRule, rand), Check: resource.ComposeTestCheckFunc( random.TestCheckResourceAttr("auth0_rule.my_rule", "name", "acceptance-test-{{.random}}", rand), diff --git a/auth0/resource_auth0_user.go b/auth0/resource_auth0_user.go index 52d50a4f..46f8b26a 100644 --- a/auth0/resource_auth0_user.go +++ b/auth0/resource_auth0_user.go @@ -314,14 +314,14 @@ func assignUserRoles(d *schema.ResourceData, m interface{}) error { add, rm := Diff(d, "roles") var addRoles []*management.Role - for _, addRole := range add { + for _, addRole := range add.List() { addRoles = append(addRoles, &management.Role{ ID: auth0.String(addRole.(string)), }) } var rmRoles []*management.Role - for _, rmRole := range rm { + for _, rmRole := range rm.List() { rmRoles = append(rmRoles, &management.Role{ ID: auth0.String(rmRole.(string)), }) diff --git a/auth0/resource_data.go b/auth0/resource_data.go index a0f6c6ea..d3321e5c 100644 --- a/auth0/resource_data.go +++ b/auth0/resource_data.go @@ -339,14 +339,16 @@ func (s *set) List() []interface{} { // Diff accesses the value held by key and type asserts it to a set. It then // compares it's changes if any and returns what needs to be added and what // needs to be removed. -func Diff(d ResourceData, key string) (add []interface{}, rm []interface{}) { - if d.IsNewResource() { - add = Set(d, key).List() - } +func Diff(d ResourceData, key string) (add Iterator, rm Iterator) { + // Zero the add and rm sets. These may be modified if the diff observed any + // changes. + add = &set{newResourceDataAtKey(key, d), d.Get(key).(*schema.Set)} + rm = &set{newResourceDataAtKey(key, d), &schema.Set{}} + if d.HasChange(key) { o, n := d.GetChange(key) - add = n.(*schema.Set).Difference(o.(*schema.Set)).List() - rm = o.(*schema.Set).Difference(n.(*schema.Set)).List() + add = &set{newResourceDataAtKey(key, d), n.(*schema.Set).Difference(o.(*schema.Set))} + rm = &set{newResourceDataAtKey(key, d), o.(*schema.Set).Difference(n.(*schema.Set))} } return } diff --git a/auth0/structure_auth0_connection.go b/auth0/structure_auth0_connection.go index fa534780..d0419694 100644 --- a/auth0/structure_auth0_connection.go +++ b/auth0/structure_auth0_connection.go @@ -677,7 +677,7 @@ func expandConnectionOptionsScopes(d ResourceData, s scoper) { for _, scope := range add { s.SetScopes(true, scope.(string)) } - for _, scope := range rm { + for _, scope := range rm.List() { s.SetScopes(false, scope.(string)) } } diff --git a/docs/resources/organization.md b/docs/resources/organization.md new file mode 100644 index 00000000..8a3db376 --- /dev/null +++ b/docs/resources/organization.md @@ -0,0 +1,75 @@ +--- +layout: "auth0" +page_title: "Auth0: auth0_organization" +description: |- + The Organizations feature represents a broad update to the Auth0 platform that + allows our business-to-business (B2B) customers to better manage their partners + and customers, and to customize the ways that end-users access their + applications. Auth0 customers can use Organizations to: + + - Represent their business customers and partners in Auth0 and manage their + membership. + - Configure branded, federated login flows for each business. + - Build administration capabilities into their products, using Organizations + APIs, so that those businesses can manage their own organizations. +--- + +# auth0_organization + +The Organizations feature represents a broad update to the Auth0 platform that +allows our business-to-business (B2B) customers to better manage their partners +and customers, and to customize the ways that end-users access their +applications. Auth0 customers can use Organizations to: + + - Represent their business customers and partners in Auth0 and manage their + membership. + - Configure branded, federated login flows for each business. + - Build administration capabilities into their products, using Organizations + APIs, so that those businesses can manage their own organizations. + +## Example Usage + +```hcl +resource auth0_organization acme { + name = "acme" + display_name = "Acme Inc." + branding { + logo_url = "https://acme.com/logo.svg" + colors = { + primary = "#e3e2f0" + page_background = "#e3e2ff" + } + } + connections { + connection_id = auth0_connection.acme.id + assign_membership_on_login = true + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) The name of this organization +* `display_name` – (Optional) Friendly name of this organization +* `branding` – (Optional) Defines how to style the login pages. For details, see + [Branding](#branding) +* `metadata` - (Optional) Metadata associated with the organization, Maximum of + 10 metadata properties allowed +* `connections` – (Optional) Connections assigned to the organization. For + details, see [Connections](#connections) + +### Branding + +* `logo_url` - (Optional) URL of logo to display on login page +* `colors` - (Optional) Color scheme used to customize the login pages + +### Connections + +* `connection_id` – (Required) The connection ID of the connection to add to the + organization +* `assign_membership_on_login` – (Optional) When true, all users that log in + with this connection will be automatically granted membership in the + organization. When false, users must be granted membership in the organization + before logging in with this connection. diff --git a/go.mod b/go.mod index 473c5caa..9c44e24b 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,5 @@ go 1.16 require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/terraform-plugin-sdk v1.16.1 - gopkg.in/auth0.v5 v5.19.2 + gopkg.in/auth0.v5 v5.20.0 ) diff --git a/go.sum b/go.sum index 5e20ffd1..953de6d0 100644 --- a/go.sum +++ b/go.sum @@ -596,6 +596,8 @@ google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= gopkg.in/auth0.v5 v5.19.2 h1:GE7Xr+qcLO2ZkuGbaJfMv3VfWxhKoAQDD9P6q8ChMGM= gopkg.in/auth0.v5 v5.19.2/go.mod h1:ZUc29HB1p9iYkA1ti2uz/kVL3I9vg+Hs+qFjHKub9SM= +gopkg.in/auth0.v5 v5.20.0 h1:KytQF0ysIMrjK/AzlA2AYH44Zfv89qSrb2R7YzwQ/bM= +gopkg.in/auth0.v5 v5.20.0/go.mod h1:k1eJq1+II4rwUlecBabE7u4igEuzKUCEZAMa11PUfQk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=