diff --git a/.changelog/855.txt b/.changelog/855.txt new file mode 100644 index 000000000..ff9e6fa76 --- /dev/null +++ b/.changelog/855.txt @@ -0,0 +1,3 @@ +```release-note:feature +resource/organization: Allows managing the members of an organization: They can now be invited to the organization (and later removed) and their assigned roles can be updated. +``` diff --git a/docs/resources/organization.md b/docs/resources/organization.md new file mode 100644 index 000000000..e4f7dfda1 --- /dev/null +++ b/docs/resources/organization.md @@ -0,0 +1,120 @@ +--- +page_title: "Elastic Cloud: ec_organization Resource" +description: |- + Manages an Elastic Cloud organization membership. + + ~> **This resource can only be used with Elastic Cloud SaaS** +--- + +# Resource: ec_organization + +Manages an Elastic Cloud organization membership. + + ~> **This resource can only be used with Elastic Cloud SaaS** + +## Example Usage + +### Import + +To import an organization into terraform, first define your organization configuration in your terraform file. For example: +```terraform +resource "ec_organization" "myorg" { +} +``` + +Then import the organization using your organization-id (The organization id can be found on [the organization page](https://cloud.elastic.co/account/members)) +```bash +terraform import ec_organization.myorg +``` + +Now you can run `terraform plan` to see if there are any diffs between your config and how your organization is currently configured. + +### Basic + +```terraform +resource "ec_organization" "my_org" { + members = { + "a.member@example.com" = { + # All role definitions are optional + + # Define roles for the whole organization + # Available roles are documented here: https://www.elastic.co/guide/en/cloud/current/ec-user-privileges.html#ec_organization_level_roles + organization_role = "billing-admin" + + # Define deployment-specific roles + # Available roles are documented here: https://www.elastic.co/guide/en/cloud/current/ec-user-privileges.html#ec_instance_access_roles + deployment_roles = [ + # A role can be given for all deployments + { + role = "editor" + all_deployments = true + }, + + # Or just for specific deployments + { + role = "editor" + deployment_ids = ["ce03a623751b4fc98d48400fec58b9c0"] + } + ] + + # Define roles for elasticsearch projects (Docs: https://www.elastic.co/docs/current/serverless/general/assign-user-roles#es) + project_elasticsearch_roles = [ + # A role can be given for all projects + { + role = "admin" + all_projects = true + }, + + # Or just for specific projects + { + role = "admin" + project_ids = ["c866244b611442d585e23a0cc8c9434c"] + } + ] + + project_observability_roles = [ + # Same as for an elasticsearch project + # Available roles are documented here: https://www.elastic.co/docs/current/serverless/general/assign-user-roles#observability + ] + + project_security_roles = [ + # Same as for an elasticsearch project + # Available roles are documented here: https://www.elastic.co/docs/current/serverless/general/assign-user-roles#security + ] + } + } +} +``` + +### Use variables to give the same roles to multiple users + +```terraform +# To simplify managing multiple members with the same roles, the roles can be assigned to local variables +locals { + deployment_admin = { + deployment_roles = [ + { + role = "admin" + all_deployments = true + } + ] + } + + deployment_viewer = { + deployment_roles = [ + { + role = "viewer" + all_deployments = true + } + ] + } +} + +resource "ec_organization" "my_org" { + members = { + "admin@example.com" = local.deployment_admin + "viewer@example.com" = local.deployment_viewer + "another.viewer@example.com" = local.deployment_viewer + } +} +``` \ No newline at end of file diff --git a/ec/ecdatasource/deploymentsdatasource/datasource_test.go b/ec/ecdatasource/deploymentsdatasource/datasource_test.go index 7ba99388b..882bb7b6c 100644 --- a/ec/ecdatasource/deploymentsdatasource/datasource_test.go +++ b/ec/ecdatasource/deploymentsdatasource/datasource_test.go @@ -38,7 +38,7 @@ func Test_modelToState(t *testing.T) { } wantDeployments := modelV0{ - ID: types.StringValue("2705093922"), + ID: types.StringValue("3192409966"), NamePrefix: types.StringValue("test"), ReturnCount: types.Int64Value(1), DeploymentTemplateID: types.StringValue("azure-compute-optimized"), diff --git a/ec/ecresource/organizationresource/create.go b/ec/ecresource/organizationresource/create.go new file mode 100644 index 000000000..c5c8d1b97 --- /dev/null +++ b/ec/ecresource/organizationresource/create.go @@ -0,0 +1,60 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource + +import ( + "context" + "github.com/elastic/cloud-sdk-go/pkg/api/organizationapi" + "github.com/elastic/cloud-sdk-go/pkg/models" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *Resource) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) { + // It is not possible to create an organization, it already exists + // Instead, just import the already existing organization + response.Diagnostics.AddError("organization already exists", "please import the organization using terraform import") +} + +func (r *Resource) createInvitation(ctx context.Context, email string, plan OrganizationMember, organizationID string, diagnostics *diag.Diagnostics) *OrganizationMember { + apiModel := modelToApi(ctx, plan, organizationID, diagnostics) + if diagnostics.HasError() { + return nil + } + + invitations, err := organizationapi.CreateInvitation(organizationapi.CreateInvitationParams{ + API: r.client, + OrganizationID: organizationID, + Emails: []string{email}, + ExpiresIn: "7d", + RoleAssignments: apiModel.RoleAssignments, + }) + if err != nil { + diagnostics.Append(diag.NewErrorDiagnostic("Failed to create invitation", err.Error())) + return nil + } + + invitation := invitations.Invitations[0] + organizationMember := apiToModel(ctx, models.OrganizationMembership{ + Email: *invitation.Email, + OrganizationID: invitation.Organization.ID, + RoleAssignments: invitation.RoleAssignments, + }, true, diagnostics) + + return organizationMember +} diff --git a/ec/ecresource/organizationresource/delete.go b/ec/ecresource/organizationresource/delete.go new file mode 100644 index 000000000..ead738e9d --- /dev/null +++ b/ec/ecresource/organizationresource/delete.go @@ -0,0 +1,70 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource + +import ( + "context" + "github.com/elastic/cloud-sdk-go/pkg/api/organizationapi" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *Resource) Delete(ctx context.Context, request resource.DeleteRequest, response *resource.DeleteResponse) { + // It is not possible to delete an organization +} + +func (r *Resource) deleteMember(email string, member OrganizationMember, organizationID string, diags *diag.Diagnostics) { + if member.InvitationPending.ValueBool() { + r.deleteInvitation(email, organizationID, diags) + } else { + _, err := organizationapi.DeleteMember(organizationapi.DeleteMemberParams{ + API: r.client, + OrganizationID: organizationID, + UserIDs: []string{member.UserID.ValueString()}, + }) + if err != nil { + diags.Append(diag.NewErrorDiagnostic("Removing organization member failed.", err.Error())) + return + } + } +} + +func (r *Resource) deleteInvitation(email string, organizationID string, diags *diag.Diagnostics) { + invitations, err := organizationapi.ListInvitations(organizationapi.ListInvitationsParams{ + API: r.client, + OrganizationID: organizationID, + }) + if err != nil { + diags.Append(diag.NewErrorDiagnostic("Listing organization members failed", err.Error())) + return + } + for _, invitation := range invitations.Invitations { + if *invitation.Email == email { + _, err := organizationapi.DeleteInvitation(organizationapi.DeleteInvitationParams{ + API: r.client, + OrganizationID: organizationID, + InvitationTokens: []string{*invitation.Token}, + }) + if err != nil { + diags.Append(diag.NewErrorDiagnostic("Removing member invitation failed", err.Error())) + return + } + return + } + } +} diff --git a/ec/ecresource/organizationresource/import.go b/ec/ecresource/organizationresource/import.go new file mode 100644 index 000000000..67ce50037 --- /dev/null +++ b/ec/ecresource/organizationresource/import.go @@ -0,0 +1,34 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource + +import ( + "context" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *Resource) ImportState(ctx context.Context, request resource.ImportStateRequest, response *resource.ImportStateResponse) { + organizationID := request.ID + + result := r.readFromApi(ctx, organizationID, &response.Diagnostics) + if response.Diagnostics.HasError() { + return + } + + response.State.Set(ctx, &result) +} diff --git a/ec/ecresource/organizationresource/list_difference.go b/ec/ecresource/organizationresource/list_difference.go new file mode 100644 index 000000000..c2277c1bd --- /dev/null +++ b/ec/ecresource/organizationresource/list_difference.go @@ -0,0 +1,43 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource + +// Returns all the elements from array a that are not in array b +func difference[T interface{}](a, b []*T, getKey func(T) string) []*T { + var diff []*T + m := make(map[string]T) + for _, item := range b { + if item == nil { + continue + } + key := getKey(*item) + m[key] = *item + } + + for _, item := range a { + if item == nil { + continue + } + key := getKey(*item) + if _, ok := m[key]; !ok { + diff = append(diff, item) + } + } + + return diff +} diff --git a/ec/ecresource/organizationresource/list_difference_test.go b/ec/ecresource/organizationresource/list_difference_test.go new file mode 100644 index 000000000..3aa2722c5 --- /dev/null +++ b/ec/ecresource/organizationresource/list_difference_test.go @@ -0,0 +1,88 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource + +import ( + "github.com/elastic/cloud-sdk-go/pkg/util/ec" + "reflect" + "testing" +) + +func Test_difference(t *testing.T) { + type args struct { + a []*string + b []*string + } + type testCase struct { + name string + args args + want []*string + } + tests := []testCase{ + { + name: "returns elements that are only in a", + args: args{ + a: []*string{ec.String("a"), ec.String("b")}, + b: []*string{ec.String("b"), ec.String("c")}, + }, + want: []*string{ec.String("a")}, + }, + { + name: "both lists empty, returns empty list", + args: args{ + a: nil, + b: nil, + }, + want: nil, + }, + { + name: "if b empty, returns a", + args: args{ + a: []*string{ec.String("a")}, + b: nil, + }, + want: []*string{ec.String("a")}, + }, + { + name: "if b has no elements in a, return a", + args: args{ + a: []*string{ec.String("a")}, + b: []*string{ec.String("b")}, + }, + want: []*string{ec.String("a")}, + }, + { + name: "if b has all elements of a, return empty list", + args: args{ + a: []*string{ec.String("a")}, + b: []*string{ec.String("a"), ec.String("b")}, + }, + want: nil, + }, + } + for _, tt := range tests { + getKey := func(a string) string { + return a + } + t.Run(tt.name, func(t *testing.T) { + if got := difference(tt.args.a, tt.args.b, getKey); !reflect.DeepEqual(got, tt.want) { + t.Errorf("difference() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/ec/ecresource/organizationresource/mapper_roles.go b/ec/ecresource/organizationresource/mapper_roles.go new file mode 100644 index 000000000..5f2ddaabd --- /dev/null +++ b/ec/ecresource/organizationresource/mapper_roles.go @@ -0,0 +1,40 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource + +import "strings" + +type RoleType string + +const ( + deployment = "deployment" + projectElasticsearch = "elasticsearch" + projectObservability = "observability" + projectSecurity = "security" +) + +// Adds the prefix to a role (e.g. admin -> elasticsearch-admin) +func roleModelToApi(modelRole string, roleType RoleType) *string { + apiRole := string(roleType) + "-" + modelRole + return &apiRole +} + +// Removes the prefix from a role (e.g. elasticsearch-admin -> admin) +func roleApiToModel(apiRole string, roleType RoleType) string { + return strings.TrimPrefix(apiRole, string(roleType)+"-") +} diff --git a/ec/ecresource/organizationresource/mapper_to_api.go b/ec/ecresource/organizationresource/mapper_to_api.go new file mode 100644 index 000000000..30355fa55 --- /dev/null +++ b/ec/ecresource/organizationresource/mapper_to_api.go @@ -0,0 +1,142 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource + +import ( + "context" + "github.com/elastic/cloud-sdk-go/pkg/models" + "github.com/elastic/cloud-sdk-go/pkg/util/ec" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "sort" +) + +func modelToApi(ctx context.Context, m OrganizationMember, organizationID string, diagnostics *diag.Diagnostics) *models.OrganizationMembership { + // org + var apiOrgRoleAssignments []*models.OrganizationRoleAssignment + if !m.OrganizationRole.IsNull() && !m.OrganizationRole.IsUnknown() { + apiOrgRoleAssignments = append(apiOrgRoleAssignments, &models.OrganizationRoleAssignment{ + OrganizationID: ec.String(organizationID), + RoleID: m.OrganizationRole.ValueStringPointer(), + }) + } + + // deployment + var modelDeploymentRoles []DeploymentRoleAssignment + diags := m.DeploymentRoles.ElementsAs(ctx, &modelDeploymentRoles, false) + if diags.HasError() { + diagnostics.Append(diags...) + return nil + } + var apiDeploymentRoleAssignments []*models.DeploymentRoleAssignment + for _, roleAssignment := range modelDeploymentRoles { + + var deploymentIds []string + diags = roleAssignment.DeploymentIDs.ElementsAs(ctx, &deploymentIds, false) + if diags.HasError() { + diagnostics.Append(diags...) + return nil + } + sort.Strings(deploymentIds) + + var applicationRoles []string + diags = roleAssignment.ApplicationRoles.ElementsAs(ctx, &applicationRoles, false) + if diags.HasError() { + diagnostics.Append(diags...) + return nil + } + sort.Strings(applicationRoles) + + apiDeploymentRoleAssignments = append(apiDeploymentRoleAssignments, &models.DeploymentRoleAssignment{ + OrganizationID: ec.String(organizationID), + RoleID: roleModelToApi(roleAssignment.Role.ValueString(), deployment), + All: roleAssignment.ForAllDeployments.ValueBoolPointer(), + DeploymentIds: deploymentIds, + ApplicationRoles: applicationRoles, + }) + } + + // elasticsearch + apiElasticsearchRoles := projectRolesModelToApi(ctx, m.ProjectElasticsearchRoles, projectElasticsearch, organizationID, diagnostics) + if diagnostics.HasError() { + return nil + } + + // observability + apiObservabilityRoles := projectRolesModelToApi(ctx, m.ProjectObservabilityRoles, projectObservability, organizationID, diagnostics) + if diagnostics.HasError() { + return nil + } + + // security + apiSecurityRoles := projectRolesModelToApi(ctx, m.ProjectSecurityRoles, projectSecurity, organizationID, diagnostics) + if diagnostics.HasError() { + return nil + } + + apiRoleAssignments := models.RoleAssignments{ + Organization: apiOrgRoleAssignments, + Deployment: apiDeploymentRoleAssignments, + Project: &models.ProjectRoleAssignments{ + Elasticsearch: apiElasticsearchRoles, + Observability: apiObservabilityRoles, + Security: apiSecurityRoles, + }, + } + return &models.OrganizationMembership{ + Email: m.Email.ValueString(), + UserID: m.UserID.ValueStringPointer(), + RoleAssignments: &apiRoleAssignments, + } +} + +func projectRolesModelToApi(ctx context.Context, roles types.Set, roleType RoleType, organizationID string, diagnostics *diag.Diagnostics) []*models.ProjectRoleAssignment { + var modelRoles []ProjectRoleAssignment + diags := roles.ElementsAs(ctx, &modelRoles, false) + if diags.HasError() { + diagnostics.Append(diags...) + return nil + } + var apiRoles []*models.ProjectRoleAssignment + for _, roleAssignment := range modelRoles { + var projectIds []string + diags = roleAssignment.ProjectIDs.ElementsAs(ctx, &projectIds, false) + if diags.HasError() { + diagnostics.Append(diags...) + return nil + } + sort.Strings(projectIds) + + var applicationRoles []string + diags = roleAssignment.ApplicationRoles.ElementsAs(ctx, &applicationRoles, false) + if diags.HasError() { + diagnostics.Append(diags...) + return nil + } + sort.Strings(applicationRoles) + + apiRoles = append(apiRoles, &models.ProjectRoleAssignment{ + OrganizationID: ec.String(organizationID), + RoleID: roleModelToApi(roleAssignment.Role.ValueString(), roleType), + All: roleAssignment.ForAllProjects.ValueBoolPointer(), + ProjectIds: projectIds, + ApplicationRoles: applicationRoles, + }) + } + return apiRoles +} diff --git a/ec/ecresource/organizationresource/mapper_to_model.go b/ec/ecresource/organizationresource/mapper_to_model.go new file mode 100644 index 000000000..3e480cc00 --- /dev/null +++ b/ec/ecresource/organizationresource/mapper_to_model.go @@ -0,0 +1,200 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource + +import ( + "context" + "github.com/elastic/cloud-sdk-go/pkg/models" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func apiToModel(ctx context.Context, member models.OrganizationMembership, invitationPending bool, diagnostics *diag.Diagnostics) *OrganizationMember { + organizationRole := organizationRoleApiToModel(member) + + deploymentRoles := deploymentRolesApiToModel(ctx, member, diagnostics) + if diagnostics.HasError() { + return nil + } + + projectElasticsearchRoles := elasticsearchRolesApiToModel(ctx, member, diagnostics) + if diagnostics.HasError() { + return nil + } + + projectObservabilityRoles := observabilityRolesApiToModel(ctx, member, diagnostics) + if diagnostics.HasError() { + return nil + } + + projectSecurityRoles := securityRolesApiToModel(ctx, member, diagnostics) + if diagnostics.HasError() { + return nil + } + + return &OrganizationMember{ + Email: types.StringValue(member.Email), + InvitationPending: types.BoolValue(invitationPending), + UserID: types.StringValue(nilToEmpty(member.UserID)), + OrganizationRole: organizationRole, + DeploymentRoles: *deploymentRoles, + ProjectElasticsearchRoles: *projectElasticsearchRoles, + ProjectObservabilityRoles: *projectObservabilityRoles, + ProjectSecurityRoles: *projectSecurityRoles, + } +} + +func nilToEmpty(id *string) string { + if id == nil { + return "" + } + return *id +} + +func organizationRoleApiToModel(member models.OrganizationMembership) types.String { + if member.RoleAssignments != nil && + len(member.RoleAssignments.Organization) > 0 && + member.RoleAssignments.Organization[0] != nil && + member.RoleAssignments.Organization[0].RoleID != nil { + id := member.RoleAssignments.Organization[0].RoleID + return types.StringValue(*id) + } else { + return types.StringNull() + } +} + +func deploymentRolesApiToModel(ctx context.Context, member models.OrganizationMembership, diagnostics *diag.Diagnostics) *types.Set { + var result []DeploymentRoleAssignment + if member.RoleAssignments != nil { + for _, roleAssignment := range member.RoleAssignments.Deployment { + if roleAssignment.RoleID == nil { + diagnostics.Append(diag.NewErrorDiagnostic("API Error", "API returned role assignment without role")) + return nil + } + + deploymentIds, diags := types.SetValueFrom(ctx, types.StringType, roleAssignment.DeploymentIds) + if diags.HasError() { + diagnostics.Append(diags...) + return nil + } + applicationRoles, diags := types.SetValueFrom(ctx, types.StringType, roleAssignment.ApplicationRoles) + if diags.HasError() { + diagnostics.Append(diags...) + return nil + } + + result = append(result, DeploymentRoleAssignment{ + Role: types.StringValue(roleApiToModel(*roleAssignment.RoleID, deployment)), + ForAllDeployments: forAllApiToModel(roleAssignment.All), + DeploymentIDs: deploymentIds, + ApplicationRoles: applicationRoles, + }) + } + } + roleAssignments, diags := types.SetValueFrom(ctx, deploymentRoleAssignmentsSchema().NestedObject.GetAttributes().Type(), result) + if diags.HasError() { + diagnostics.Append(diags...) + return nil + } + + return &roleAssignments +} + +func elasticsearchRolesApiToModel(ctx context.Context, member models.OrganizationMembership, diagnostics *diag.Diagnostics) *types.Set { + rolesSchema := projectElasticsearchRolesSchema() + if member.RoleAssignments != nil && member.RoleAssignments.Project != nil && member.RoleAssignments.Project.Elasticsearch != nil { + return rolesApiToModel(ctx, member.RoleAssignments.Project.Elasticsearch, rolesSchema, "elasticsearch", diagnostics) + } else { + return emptySet() + } +} + +func observabilityRolesApiToModel(ctx context.Context, member models.OrganizationMembership, diagnostics *diag.Diagnostics) *types.Set { + rolesSchema := projectObservabilityRolesSchema() + if member.RoleAssignments != nil && member.RoleAssignments.Project != nil && member.RoleAssignments.Project.Observability != nil { + return rolesApiToModel(ctx, member.RoleAssignments.Project.Observability, rolesSchema, "observability", diagnostics) + } else { + return emptySet() + } +} + +func securityRolesApiToModel(ctx context.Context, member models.OrganizationMembership, diagnostics *diag.Diagnostics) *types.Set { + rolesSchema := projectSecurityRolesSchema() + if member.RoleAssignments != nil && member.RoleAssignments.Project != nil && member.RoleAssignments.Project.Security != nil { + return rolesApiToModel(ctx, member.RoleAssignments.Project.Security, rolesSchema, "security", diagnostics) + } else { + return emptySet() + } +} + +func emptySet() *types.Set { + value := types.SetValueMust(projectRoleAssignmentSchema([]string{}).Type(), []attr.Value{}) + return &value +} + +func rolesApiToModel( + ctx context.Context, + apiRoleAssignments []*models.ProjectRoleAssignment, + schema schema.SetNestedAttribute, + roleType RoleType, + diagnostics *diag.Diagnostics, +) *types.Set { + var result []ProjectRoleAssignment + + for _, roleAssignment := range apiRoleAssignments { + if roleAssignment.RoleID == nil { + diagnostics.Append(diag.NewErrorDiagnostic("API Error", "API returned role assignment without role")) + return nil + } + + projectIds, diags := types.SetValueFrom(ctx, types.StringType, roleAssignment.ProjectIds) + if diags.HasError() { + diagnostics.Append(diags...) + return nil + } + applicationRoles, diags := types.SetValueFrom(ctx, types.StringType, roleAssignment.ApplicationRoles) + if diags.HasError() { + diagnostics.Append(diags...) + return nil + } + + result = append(result, ProjectRoleAssignment{ + Role: types.StringValue(roleApiToModel(*roleAssignment.RoleID, roleType)), + ForAllProjects: forAllApiToModel(roleAssignment.All), + ProjectIDs: projectIds, + ApplicationRoles: applicationRoles, + }) + } + + roleAssignments, diags := types.SetValueFrom(ctx, schema.NestedObject.GetAttributes().Type(), result) + if diags.HasError() { + diagnostics.Append(diags...) + return nil + } + + return &roleAssignments +} + +func forAllApiToModel(apiAll *bool) types.Bool { + if apiAll == nil { + return types.BoolValue(false) + } + return types.BoolValue(*apiAll) +} diff --git a/ec/ecresource/organizationresource/mapper_to_model_test.go b/ec/ecresource/organizationresource/mapper_to_model_test.go new file mode 100644 index 000000000..42e878344 --- /dev/null +++ b/ec/ecresource/organizationresource/mapper_to_model_test.go @@ -0,0 +1,88 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource + +import ( + "context" + "github.com/elastic/cloud-sdk-go/pkg/models" + "github.com/hashicorp/terraform-plugin-framework/diag" + "testing" +) + +func TestEmptyDataCanBeMappedWithoutPanic(t *testing.T) { + tests := []struct { + name string + data models.OrganizationMembership + }{ + { + name: "Empty membership", + data: models.OrganizationMembership{}, + }, + { + name: "Empty role assignments", + data: models.OrganizationMembership{ + RoleAssignments: &models.RoleAssignments{}, + }, + }, + { + name: "Empty roles - Deployment", + data: models.OrganizationMembership{ + RoleAssignments: &models.RoleAssignments{ + Deployment: []*models.DeploymentRoleAssignment{{}}, + Organization: []*models.OrganizationRoleAssignment{{}}, + }, + }, + }, + { + name: "Empty roles - Elasticsearch", + data: models.OrganizationMembership{ + RoleAssignments: &models.RoleAssignments{ + Project: &models.ProjectRoleAssignments{ + Elasticsearch: []*models.ProjectRoleAssignment{{}}, + }, + }, + }, + }, + { + name: "Empty roles - Observability", + data: models.OrganizationMembership{ + RoleAssignments: &models.RoleAssignments{ + Project: &models.ProjectRoleAssignments{ + Observability: []*models.ProjectRoleAssignment{{}}, + }, + }, + }, + }, + { + name: "Empty roles - Security", + data: models.OrganizationMembership{ + RoleAssignments: &models.RoleAssignments{ + Project: &models.ProjectRoleAssignments{ + Security: []*models.ProjectRoleAssignment{{}}, + }, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + diags := diag.Diagnostics{} + apiToModel(context.Background(), test.data, false, &diags) + }) + } +} diff --git a/ec/ecresource/organizationresource/read.go b/ec/ecresource/organizationresource/read.go new file mode 100644 index 000000000..d1093b188 --- /dev/null +++ b/ec/ecresource/organizationresource/read.go @@ -0,0 +1,98 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource + +import ( + "context" + "github.com/elastic/cloud-sdk-go/pkg/api/organizationapi" + "github.com/elastic/cloud-sdk-go/pkg/models" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func (r *Resource) Read(ctx context.Context, request resource.ReadRequest, response *resource.ReadResponse) { + diagnostics := &response.Diagnostics + + var organizationID string + diagnostics.Append(request.State.GetAttribute(ctx, path.Root("id"), &organizationID)...) + if diagnostics.HasError() { + return + } + + organization := r.readFromApi(ctx, organizationID, diagnostics) + if diagnostics.HasError() { + return + } + + diagnostics.Append(response.State.Set(ctx, organization)...) +} + +func (r *Resource) readFromApi(ctx context.Context, organizationID string, diagnostics *diag.Diagnostics) *Organization { + members, err := organizationapi.ListMembers(organizationapi.ListMembersParams{ + API: r.client, + OrganizationID: organizationID, + }) + if err != nil { + diagnostics.Append(diag.NewErrorDiagnostic("Listing organization members failed", err.Error())) + return nil + } + + modelMembers := make(map[string]OrganizationMember) + for _, member := range members.Members { + model := apiToModel(ctx, *member, false, diagnostics) + if diagnostics.HasError() { + return nil + } + modelMembers[model.Email.ValueString()] = *model + } + + // Members that were invited, but have not yet accepted, are listed as invitations + invitations, err := organizationapi.ListInvitations(organizationapi.ListInvitationsParams{ + API: r.client, + OrganizationID: organizationID, + }) + if err != nil { + diagnostics.Append(diag.NewErrorDiagnostic("Listing organization members failed", err.Error())) + return nil + } + + for _, invitation := range invitations.Invitations { + model := apiToModel(ctx, models.OrganizationMembership{ + Email: *invitation.Email, + OrganizationID: invitation.Organization.ID, + RoleAssignments: invitation.RoleAssignments, + }, true, diagnostics) + if diagnostics.HasError() { + return nil + } + modelMembers[model.Email.ValueString()] = *model + } + + membersMapValue, diags := types.MapValueFrom(ctx, organizationMembersSchema().NestedObject.GetAttributes().Type(), modelMembers) + if diags.HasError() { + diagnostics.Append(diags...) + return nil + } + + return &Organization{ + ID: types.StringValue(organizationID), + Members: membersMapValue, + } +} diff --git a/ec/ecresource/organizationresource/resource.go b/ec/ecresource/organizationresource/resource.go new file mode 100644 index 000000000..fd37833ac --- /dev/null +++ b/ec/ecresource/organizationresource/resource.go @@ -0,0 +1,43 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource + +import ( + "context" + "github.com/elastic/cloud-sdk-go/pkg/api" + "github.com/elastic/terraform-provider-ec/ec/internal" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +type Resource struct { + client *api.API +} + +var _ resource.Resource = &Resource{} +var _ resource.ResourceWithConfigure = &Resource{} +var _ resource.ResourceWithImportState = &Resource{} + +func (r *Resource) Metadata(ctx context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) { + response.TypeName = request.ProviderTypeName + "_organization" +} + +func (r *Resource) Configure(ctx context.Context, request resource.ConfigureRequest, response *resource.ConfigureResponse) { + client, diags := internal.ConvertProviderData(request.ProviderData) + response.Diagnostics.Append(diags...) + r.client = client.Stateful +} diff --git a/ec/ecresource/organizationresource/resource_mock_utils_test.go b/ec/ecresource/organizationresource/resource_mock_utils_test.go new file mode 100644 index 000000000..032fd91c0 --- /dev/null +++ b/ec/ecresource/organizationresource/resource_mock_utils_test.go @@ -0,0 +1,425 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource_test + +import ( + "github.com/elastic/cloud-sdk-go/pkg/api" + "github.com/elastic/cloud-sdk-go/pkg/api/mock" + "github.com/elastic/cloud-sdk-go/pkg/models" + "github.com/elastic/cloud-sdk-go/pkg/util/ec" + "github.com/go-openapi/strfmt" +) + +var orgId = ec.String("123") + +func getMembers(memberships []*models.OrganizationMembership) mock.Response { + return mock.New200ResponseAssertion( + &mock.RequestAssertion{ + Host: api.DefaultMockHost, + Header: api.DefaultReadMockHeaders, + Method: "GET", + Path: "/api/v1/organizations/123/members", + }, + mock.NewStructBody(models.OrganizationMemberships{ + Members: memberships, + }), + ) +} + +func getMembersFails() mock.Response { + return mock.New404ResponseAssertion( + &mock.RequestAssertion{ + Host: api.DefaultMockHost, + Header: api.DefaultReadMockHeaders, + Method: "GET", + Path: "/api/v1/organizations/123/members", + }, + mock.NewStructBody(models.BasicFailedReply{ + Errors: []*models.BasicFailedReplyElement{ + { + Message: ec.String("organization-does-not-exist"), + }, + }}), + ) +} + +func getInvitations(invitations []*models.OrganizationInvitation) mock.Response { + return mock.New200ResponseAssertion( + &mock.RequestAssertion{ + Host: api.DefaultMockHost, + Header: api.DefaultReadMockHeaders, + Method: "GET", + Path: "/api/v1/organizations/123/invitations", + }, + mock.NewStructBody(models.OrganizationInvitations{ + Invitations: invitations, + }), + ) +} + +func getInvitationsFails() mock.Response { + return mock.New404ResponseAssertion( + &mock.RequestAssertion{ + Host: api.DefaultMockHost, + Header: api.DefaultReadMockHeaders, + Method: "GET", + Path: "/api/v1/organizations/123/invitations", + }, + mock.NewStructBody(models.BasicFailedReply{ + Errors: []*models.BasicFailedReplyElement{ + { + Message: ec.String("organization-does-not-exist"), + }, + }}), + ) +} + +func createInvitation(invitation *models.OrganizationInvitation) mock.Response { + return mock.New201ResponseAssertion( + &mock.RequestAssertion{ + Host: api.DefaultMockHost, + Header: api.DefaultWriteMockHeaders, + Method: "POST", + Path: "/api/v1/organizations/123/invitations", + Body: mock.NewStructBody(models.OrganizationInvitationRequest{ + Emails: []string{*invitation.Email}, + ExpiresIn: "7d", + RoleAssignments: invitation.RoleAssignments, + }), + }, + mock.NewStructBody(models.OrganizationInvitations{ + Invitations: []*models.OrganizationInvitation{ + invitation, + }, + }), + ) +} + +func createInvitationFails(invitation *models.OrganizationInvitation) mock.Response { + return mock.New400ResponseAssertion( + &mock.RequestAssertion{ + Host: api.DefaultMockHost, + Header: api.DefaultWriteMockHeaders, + Method: "POST", + Path: "/api/v1/organizations/123/invitations", + Body: mock.NewStructBody(models.OrganizationInvitationRequest{ + Emails: []string{*invitation.Email}, + ExpiresIn: "7d", + RoleAssignments: invitation.RoleAssignments, + }), + }, + mock.NewStructBody(models.BasicFailedReply{ + Errors: []*models.BasicFailedReplyElement{ + { + Message: ec.String("organization.invitation_invalid_email"), + }, + }}), + ) +} + +func deleteInvitation(invitation *models.OrganizationInvitation) mock.Response { + return mock.New200ResponseAssertion( + &mock.RequestAssertion{ + Host: api.DefaultMockHost, + Header: api.DefaultReadMockHeaders, + Method: "DELETE", + Path: "/api/v1/organizations/123/invitations/" + *invitation.Token, + }, + mock.NewStringBody("{}"), + ) +} + +func deleteInvitationFails(invitation *models.OrganizationInvitation) mock.Response { + return mock.New400ResponseAssertion( + &mock.RequestAssertion{ + Host: api.DefaultMockHost, + Header: api.DefaultReadMockHeaders, + Method: "DELETE", + Path: "/api/v1/organizations/123/invitations/" + *invitation.Token, + }, + mock.NewStructBody(models.BasicFailedReply{ + Errors: []*models.BasicFailedReplyElement{ + { + Message: ec.String("organization.invitation_token_invalid"), + }, + }}), + ) +} + +func buildInvitationModel(email string) *models.OrganizationInvitation { + timestamp, _ := strfmt.ParseDateTime("2021-01-07T22:13:42.999Z") + expiration, _ := strfmt.ParseDateTime("2023-01-07T22:13:42.999Z") + assignments := &models.RoleAssignments{ + Deployment: nil, + Organization: []*models.OrganizationRoleAssignment{ + { + OrganizationID: ec.String("123"), + RoleID: ec.String("billing-admin"), + }, + }, + Platform: nil, + Project: &models.ProjectRoleAssignments{}, + } + return &models.OrganizationInvitation{ + Token: ec.String("invitation-token"), + AcceptedAt: strfmt.DateTime{}, + CreatedAt: ×tamp, + Email: ec.String(email), + Expired: ec.Bool(false), + ExpiresAt: &expiration, + Organization: &models.Organization{ + ID: ec.String("123"), + }, + RoleAssignments: assignments, + } +} + +func addRoleAssignments() mock.Response { + return mock.New200ResponseAssertion( + &mock.RequestAssertion{ + Host: api.DefaultMockHost, + Header: api.DefaultWriteMockHeaders, + Method: "POST", + Path: "/api/v1/users/userid2/role_assignments", + Body: mock.NewStructBody(models.RoleAssignments{ + Deployment: []*models.DeploymentRoleAssignment{ + { + All: ec.Bool(false), + OrganizationID: orgId, + RoleID: ec.String("deployment-editor"), + DeploymentIds: []string{"abc"}, + }, + { + OrganizationID: orgId, + RoleID: ec.String("deployment-viewer"), + All: ec.Bool(true), + }, + }, + Project: &models.ProjectRoleAssignments{}, + }), + }, + mock.NewStringBody("{}"), + ) +} + +func addRoleAssignmentsFails() mock.Response { + return mock.New400ResponseAssertion( + &mock.RequestAssertion{ + Host: api.DefaultMockHost, + Header: api.DefaultWriteMockHeaders, + Method: "POST", + Path: "/api/v1/users/userid2/role_assignments", + Body: mock.NewStructBody(models.RoleAssignments{ + Deployment: []*models.DeploymentRoleAssignment{ + { + All: ec.Bool(false), + OrganizationID: orgId, + RoleID: ec.String("deployment-editor"), + DeploymentIds: []string{"abc"}, + }, + { + OrganizationID: orgId, + RoleID: ec.String("deployment-viewer"), + All: ec.Bool(true), + }, + }, + Project: &models.ProjectRoleAssignments{}, + }), + }, + mock.NewStructBody(models.BasicFailedReply{ + Errors: []*models.BasicFailedReplyElement{ + { + Message: ec.String("role_assignments.invalid_config"), + }, + }}), + ) +} + +func removeRoleAssignments() mock.Response { + return mock.New200ResponseAssertion( + &mock.RequestAssertion{ + Host: api.DefaultMockHost, + Header: api.DefaultWriteMockHeaders, + Method: "DELETE", + Path: "/api/v1/users/userid2/role_assignments", + Body: mock.NewStructBody(models.RoleAssignments{ + Organization: []*models.OrganizationRoleAssignment{ + { + OrganizationID: orgId, + RoleID: ec.String("organization-admin"), + }, + }, + Deployment: []*models.DeploymentRoleAssignment{ + { + All: ec.Bool(false), + OrganizationID: orgId, + RoleID: ec.String("deployment-editor"), + DeploymentIds: []string{"abc"}, + }, + }, + Project: &models.ProjectRoleAssignments{}, + }), + }, + mock.NewStringBody("{}"), + ) +} + +func removeRoleAssignmentsFails() mock.Response { + return mock.New400ResponseAssertion( + &mock.RequestAssertion{ + Host: api.DefaultMockHost, + Header: api.DefaultWriteMockHeaders, + Method: "DELETE", + Path: "/api/v1/users/userid2/role_assignments", + Body: mock.NewStructBody(models.RoleAssignments{ + Organization: []*models.OrganizationRoleAssignment{ + { + OrganizationID: orgId, + RoleID: ec.String("organization-admin"), + }, + }, + Deployment: []*models.DeploymentRoleAssignment{ + { + All: ec.Bool(false), + OrganizationID: orgId, + RoleID: ec.String("deployment-editor"), + DeploymentIds: []string{"abc"}, + }, + }, + Project: &models.ProjectRoleAssignments{}, + }), + }, + mock.NewStructBody(models.BasicFailedReply{ + Errors: []*models.BasicFailedReplyElement{ + { + Message: ec.String("role_assignments.invalid_config"), + }, + }}), + ) +} + +func removeMember() mock.Response { + return mock.New200ResponseAssertion( + &mock.RequestAssertion{ + Host: api.DefaultMockHost, + Header: api.DefaultReadMockHeaders, + Method: "DELETE", + Path: "/api/v1/organizations/123/members/userid2", + }, + mock.NewStringBody("{}"), + ) +} + +func removeMemberFails() mock.Response { + return mock.New404ResponseAssertion( + &mock.RequestAssertion{ + Host: api.DefaultMockHost, + Header: api.DefaultReadMockHeaders, + Method: "DELETE", + Path: "/api/v1/organizations/123/members/userid2", + }, + mock.NewStructBody(models.BasicFailedReply{ + Errors: []*models.BasicFailedReplyElement{ + { + Message: ec.String("organization.membership_not_found"), + }, + }}), + ) +} + +func buildExistingMember() *models.OrganizationMembership { + return &models.OrganizationMembership{ + UserID: ec.String("userid"), + Email: "user@example.com", + OrganizationID: orgId, + RoleAssignments: &models.RoleAssignments{ + Organization: []*models.OrganizationRoleAssignment{ + { + OrganizationID: orgId, + RoleID: ec.String("billing-admin"), + }, + }, + Deployment: []*models.DeploymentRoleAssignment{ + { + OrganizationID: orgId, + RoleID: ec.String("deployment-viewer"), + All: ec.Bool(true), + }, + { + OrganizationID: orgId, + RoleID: ec.String("deployment-editor"), + DeploymentIds: []string{"abc"}, + }, + }, + Project: &models.ProjectRoleAssignments{ + Elasticsearch: []*models.ProjectRoleAssignment{ + { + OrganizationID: orgId, + RoleID: ec.String("elasticsearch-viewer"), + All: ec.Bool(true), + }, + { + OrganizationID: orgId, + RoleID: ec.String("elasticsearch-developer"), + ProjectIds: []string{"qwe"}, + }, + }, + Observability: []*models.ProjectRoleAssignment{ + { + OrganizationID: orgId, + RoleID: ec.String("observability-viewer"), + All: ec.Bool(true), + }, + { + OrganizationID: orgId, + RoleID: ec.String("observability-editor"), + ProjectIds: []string{"rty"}, + }, + }, + Security: []*models.ProjectRoleAssignment{ + { + OrganizationID: orgId, + RoleID: ec.String("security-viewer"), + All: ec.Bool(true), + }, + { + OrganizationID: orgId, + RoleID: ec.String("security-editor"), + ProjectIds: []string{"uio"}, + }, + }, + }, + }, + } +} + +func buildNewMember() *models.OrganizationMembership { + return &models.OrganizationMembership{ + UserID: ec.String("userid2"), + Email: "newuser@example.com", + OrganizationID: orgId, + RoleAssignments: &models.RoleAssignments{ + Organization: []*models.OrganizationRoleAssignment{ + { + OrganizationID: orgId, + RoleID: ec.String("organization-admin"), + }, + }, + }, + } +} diff --git a/ec/ecresource/organizationresource/resource_test.go b/ec/ecresource/organizationresource/resource_test.go new file mode 100644 index 000000000..d911bdbd7 --- /dev/null +++ b/ec/ecresource/organizationresource/resource_test.go @@ -0,0 +1,685 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource_test + +import ( + "fmt" + "github.com/elastic/cloud-sdk-go/pkg/api/mock" + "github.com/elastic/cloud-sdk-go/pkg/models" + "github.com/elastic/cloud-sdk-go/pkg/util/ec" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + + "github.com/elastic/cloud-sdk-go/pkg/api" + provider "github.com/elastic/terraform-provider-ec/ec" +) + +func Test(t *testing.T) { + resourceName := "ec_organization.myorg" + + baseConfig := buildConfig("") + configWithNewMember := buildConfig(addedMember) + configWithUpdatedNewMember := buildConfig(addedMemberWithUpdate) + configWithAddedRoles := buildConfig(memberWithNewRoles) + configWithRemovedRoles := buildConfig(memberWithRemovedRoles) + + newUserInvitation := buildInvitationModel("newuser@example.com") + updatedUserInvitation := buildInvitationModel("newuser@example.com") + updatedUserInvitation.RoleAssignments.Organization[0].RoleID = ec.String("organization-admin") + + existingMember := buildExistingMember() + newMember := buildNewMember() + oneMember := []*models.OrganizationMembership{existingMember} + + newMemberWithAddedRoles := buildNewMember() + newMemberWithAddedRoles.RoleAssignments.Deployment = []*models.DeploymentRoleAssignment{ + { + All: ec.Bool(false), + OrganizationID: orgId, + RoleID: ec.String("deployment-editor"), + DeploymentIds: []string{"abc"}, + }, + { + OrganizationID: orgId, + RoleID: ec.String("deployment-viewer"), + All: ec.Bool(true), + }, + } + newMemberWithRemovedRoles := buildNewMember() + newMemberWithRemovedRoles.RoleAssignments.Organization = []*models.OrganizationRoleAssignment{} + newMemberWithRemovedRoles.RoleAssignments.Deployment = []*models.DeploymentRoleAssignment{ + { + OrganizationID: orgId, + RoleID: ec.String("deployment-viewer"), + All: ec.Bool(true), + }, + } + + tests := []struct { + name string + steps []resource.TestStep + apiMock []mock.Response + }{ + { + name: "import should correctly set the state", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: baseConfig, + ImportStatePersist: true, + }, + { + Config: baseConfig, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "id", "123"), + resource.TestCheckResourceAttr(resourceName, "members.%", "1"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.email", "user@example.com"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.invitation_pending", "false"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.user_id", "userid"), + + // Organization role + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.organization_role", "billing-admin"), + + // Deployment roles + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.deployment_roles.0.role", "editor"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.deployment_roles.0.deployment_ids.0", "abc"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.deployment_roles.1.role", "viewer"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.deployment_roles.1.all_deployments", "true"), + + // Elasticsearch roles + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.project_elasticsearch_roles.0.role", "developer"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.project_elasticsearch_roles.0.project_ids.0", "qwe"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.project_elasticsearch_roles.1.role", "viewer"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.project_elasticsearch_roles.1.all_projects", "true"), + + // Observability roles + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.project_observability_roles.0.role", "editor"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.project_observability_roles.0.project_ids.0", "rty"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.project_observability_roles.1.role", "viewer"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.project_observability_roles.1.all_projects", "true"), + + // Project roles + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.project_security_roles.0.role", "editor"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.project_security_roles.0.project_ids.0", "uio"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.project_security_roles.1.role", "viewer"), + resource.TestCheckResourceAttr(resourceName, "members.user@example.com.project_security_roles.1.all_projects", "true"), + ), + }, + }, + apiMock: []mock.Response{ + // Import + getMembers(oneMember), + getInvitations(nil), + getMembers(oneMember), + getInvitations(nil), + // Apply + getMembers(oneMember), + getInvitations(nil), + getMembers(oneMember), + getInvitations(nil), + }, + }, + { + name: "a newly added member should be invited to the organization", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: baseConfig, + ImportStatePersist: true, + }, + { + Config: configWithNewMember, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "members.%", "2"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.email", "newuser@example.com"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.invitation_pending", "true"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.organization_role", "billing-admin"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.user_id", ""), + ), + }, + }, + apiMock: []mock.Response{ + // Import + getMembers(oneMember), + getInvitations(nil), + getMembers(oneMember), + getInvitations(nil), + // Apply + getMembers(oneMember), + getInvitations(nil), + createInvitation(newUserInvitation), + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{newUserInvitation}), + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{newUserInvitation}), + }, + }, + { + name: "if the invited members roles are changed, the invitation should be cancelled and re-sent (invitations can't be updated)", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: configWithNewMember, + ImportStatePersist: true, + }, + { + Config: configWithUpdatedNewMember, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "members.%", "2"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.email", "newuser@example.com"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.invitation_pending", "true"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.organization_role", "organization-admin"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.user_id", ""), + ), + }, + }, + apiMock: []mock.Response{ + // Import + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{newUserInvitation}), + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{newUserInvitation}), + // Update + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{newUserInvitation}), + getInvitations([]*models.OrganizationInvitation{newUserInvitation}), + deleteInvitation(newUserInvitation), + createInvitation(updatedUserInvitation), + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{updatedUserInvitation}), + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{updatedUserInvitation}), + }, + }, + { + name: "if the invited member accepts, the next apply should just update the state with the user-id and set invitation_pending to false", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: configWithUpdatedNewMember, + ImportStatePersist: true, + }, + { + Config: configWithUpdatedNewMember, + PlanOnly: true, // Has to be no-op plan + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "members.%", "2"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.email", "newuser@example.com"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.invitation_pending", "false"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.organization_role", "organization-admin"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.user_id", "userid2"), + ), + }, + }, + apiMock: []mock.Response{ + // Import + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{updatedUserInvitation}), + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{updatedUserInvitation}), + // Plan + getMembers([]*models.OrganizationMembership{existingMember, newMember}), + getInvitations(nil), + getMembers([]*models.OrganizationMembership{existingMember, newMember}), + getInvitations(nil), + }, + }, + { + name: "adding roles to member", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: configWithUpdatedNewMember, + ImportStatePersist: true, + }, + { + Config: configWithAddedRoles, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "members.%", "2"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.email", "newuser@example.com"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.organization_role", "organization-admin"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.deployment_roles.0.role", "editor"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.deployment_roles.0.deployment_ids.0", "abc"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.deployment_roles.1.role", "viewer"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.deployment_roles.1.all_deployments", "true"), + ), + }, + }, + apiMock: []mock.Response{ + // Import + getMembers([]*models.OrganizationMembership{existingMember, newMember}), + getInvitations(nil), + getMembers([]*models.OrganizationMembership{existingMember, newMember}), + getInvitations(nil), + // Apply + getMembers([]*models.OrganizationMembership{existingMember, newMember}), + getInvitations(nil), + addRoleAssignments(), + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithAddedRoles}), + getInvitations(nil), + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithAddedRoles}), + getInvitations(nil), + }, + }, + { + name: "removing roles from member should work", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: configWithAddedRoles, + ImportStatePersist: true, + }, + { + Config: configWithRemovedRoles, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "members.%", "2"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.email", "newuser@example.com"), + resource.TestCheckNoResourceAttr(resourceName, "members.newuser@example.com.organization_role"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.deployment_roles.0.role", "viewer"), + resource.TestCheckResourceAttr(resourceName, "members.newuser@example.com.deployment_roles.0.all_deployments", "true"), + ), + }, + }, + apiMock: []mock.Response{ + // Import + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithAddedRoles}), + getInvitations(nil), + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithAddedRoles}), + getInvitations(nil), + // Apply + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithAddedRoles}), + getInvitations(nil), + removeRoleAssignments(), + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithRemovedRoles}), + getInvitations(nil), + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithRemovedRoles}), + getInvitations(nil), + }, + }, + { + name: "remove member from organization should work", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: configWithRemovedRoles, + ImportStatePersist: true, + }, + { + Config: baseConfig, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "members.%", "1"), + resource.TestCheckNoResourceAttr(resourceName, "members.newuser@example.com"), + ), + }, + }, + apiMock: []mock.Response{ + // Import + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithRemovedRoles}), + getInvitations(nil), + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithRemovedRoles}), + getInvitations(nil), + // Apply + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithAddedRoles}), + getInvitations(nil), + removeMember(), + getMembers([]*models.OrganizationMembership{existingMember}), + getInvitations(nil), + getMembers([]*models.OrganizationMembership{existingMember}), + getInvitations(nil), + }, + }, + { + name: "un-invite member before the member accepted the invitation should work", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: configWithNewMember, + ImportStatePersist: true, + }, + // Un-invite member (where the member is removed before they have accepted the invitation) + { + Config: baseConfig, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "members.%", "1"), + resource.TestCheckNoResourceAttr(resourceName, "members.newuser@example.com"), + ), + }, + }, + apiMock: []mock.Response{ + // Import + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{newUserInvitation}), + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{newUserInvitation}), + // Remove member before invitation was accepted (cancelling invitation) + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{newUserInvitation}), + getInvitations([]*models.OrganizationInvitation{newUserInvitation}), + deleteInvitation(newUserInvitation), + getMembers(oneMember), + getInvitations(nil), + getMembers(oneMember), + getInvitations(nil), + }, + }, + { + name: "show API error if import fails because organization does not exist", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: baseConfig, + ExpectError: regexp.MustCompile("organization-does-not-exist"), + }, + }, + apiMock: []mock.Response{ + // Import + getMembersFails(), + }, + }, + { + name: "show API error if import fails because invitations could not be listed", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: baseConfig, + ExpectError: regexp.MustCompile("organization-does-not-exist"), + }, + }, + apiMock: []mock.Response{ + // Import + getMembers(oneMember), + getInvitationsFails(), + }, + }, + { + name: "show API error if inviting a member fails due to invalid config", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: baseConfig, + ImportStatePersist: true, + }, + { + Config: configWithNewMember, + ExpectError: regexp.MustCompile("organization.invitation_invalid_email"), + }, + }, + apiMock: []mock.Response{ + // Import + getMembers(oneMember), + getInvitations(nil), + getMembers(oneMember), + getInvitations(nil), + // Apply + getMembers(oneMember), + getInvitations(nil), + createInvitationFails(newUserInvitation), + }, + }, + { + name: "show API error if adding roles fails", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: configWithUpdatedNewMember, + ImportStatePersist: true, + }, + { + Config: configWithAddedRoles, + ExpectError: regexp.MustCompile("role_assignments.invalid_config"), + }, + }, + apiMock: []mock.Response{ + // Import + getMembers([]*models.OrganizationMembership{existingMember, newMember}), + getInvitations(nil), + getMembers([]*models.OrganizationMembership{existingMember, newMember}), + getInvitations(nil), + // Apply + getMembers([]*models.OrganizationMembership{existingMember, newMember}), + getInvitations(nil), + addRoleAssignmentsFails(), + }, + }, + { + name: "show API error if removing roles fails due to API error", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: configWithAddedRoles, + ImportStatePersist: true, + }, + { + Config: configWithRemovedRoles, + ExpectError: regexp.MustCompile("role_assignments.invalid_config"), + }, + }, + apiMock: []mock.Response{ + // Import + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithAddedRoles}), + getInvitations(nil), + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithAddedRoles}), + getInvitations(nil), + // Apply + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithAddedRoles}), + getInvitations(nil), + removeRoleAssignmentsFails(), + }, + }, + { + name: "show API error if remove member fails", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: configWithRemovedRoles, + ImportStatePersist: true, + }, + { + Config: baseConfig, + ExpectError: regexp.MustCompile("organization.membership_not_found"), + }, + }, + apiMock: []mock.Response{ + // Import + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithRemovedRoles}), + getInvitations(nil), + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithRemovedRoles}), + getInvitations(nil), + // Apply + getMembers([]*models.OrganizationMembership{existingMember, newMemberWithAddedRoles}), + getInvitations(nil), + removeMemberFails(), + }, + }, + { + name: "show API error if invitation delete fails", + steps: []resource.TestStep{ + { + ImportState: true, + ResourceName: "ec_organization.myorg", + ImportStateId: "123", + Config: configWithNewMember, + ImportStatePersist: true, + }, + { + Config: baseConfig, + ExpectError: regexp.MustCompile("organization.invitation_token_invalid"), + }, + }, + apiMock: []mock.Response{ + // Import + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{newUserInvitation}), + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{newUserInvitation}), + // Remove member before invitation was accepted (cancelling invitation) + getMembers(oneMember), + getInvitations([]*models.OrganizationInvitation{newUserInvitation}), + getInvitations([]*models.OrganizationInvitation{newUserInvitation}), + deleteInvitationFails(newUserInvitation), + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactoriesWithMockClient( + api.NewMock(test.apiMock...), + ), + Steps: test.steps, + }) + }) + } +} + +func buildConfig(newUser string) string { + return fmt.Sprintf(` +resource "ec_organization" "myorg" { + members = { + "user@example.com" = { + organization_role = "billing-admin" + + deployment_roles = [ + { + role = "viewer" + all_deployments = true + }, + { + role = "editor" + deployment_ids = ["abc"] + } + ] + + project_elasticsearch_roles = [ + { + role = "viewer" + all_projects = true + }, + { + role = "developer" + project_ids = ["qwe"] + } + ] + + project_observability_roles = [ + { + role = "viewer" + all_projects = true + }, + { + role = "editor" + project_ids = ["rty"] + } + ] + + project_security_roles = [ + { + role = "viewer" + all_projects = true + }, + { + role = "editor" + project_ids = ["uio"] + } + ] + } + %s + } +} +`, newUser) +} + +const addedMember = ` + "newuser@example.com" = { + organization_role = "billing-admin" + } +` + +const addedMemberWithUpdate = ` + "newuser@example.com" = { + organization_role = "organization-admin" + } +` + +const memberWithNewRoles = ` + "newuser@example.com" = { + organization_role = "organization-admin" + + deployment_roles = [ + { + role = "viewer" + all_deployments = true + }, + { + role = "editor" + deployment_ids = ["abc"] + } + ] + } +` + +const memberWithRemovedRoles = ` + "newuser@example.com" = { + deployment_roles = [ + { + role = "viewer" + all_deployments = true + } + ] + } +` + +func protoV6ProviderFactoriesWithMockClient(client *api.API) map[string]func() (tfprotov6.ProviderServer, error) { + return map[string]func() (tfprotov6.ProviderServer, error){ + "ec": func() (tfprotov6.ProviderServer, error) { + return providerserver.NewProtocol6(provider.ProviderWithClient(client, "unit-tests"))(), nil + }, + } +} diff --git a/ec/ecresource/organizationresource/schema.go b/ec/ecresource/organizationresource/schema.go new file mode 100644 index 000000000..d4d4da669 --- /dev/null +++ b/ec/ecresource/organizationresource/schema.go @@ -0,0 +1,242 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource + +import ( + "context" + "fmt" + "github.com/elastic/terraform-provider-ec/ec/internal/planmodifiers" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" +) + +type Organization struct { + ID types.String `tfsdk:"id"` + Members types.Map `tfsdk:"members"` //< OrganizationMember +} + +type OrganizationMember struct { + Email types.String `tfsdk:"email"` + InvitationPending types.Bool `tfsdk:"invitation_pending"` + UserID types.String `tfsdk:"user_id"` + OrganizationRole types.String `tfsdk:"organization_role"` + DeploymentRoles types.Set `tfsdk:"deployment_roles"` //< DeploymentRoleAssignment + ProjectElasticsearchRoles types.Set `tfsdk:"project_elasticsearch_roles"` //< ProjectRoleAssignment + ProjectObservabilityRoles types.Set `tfsdk:"project_observability_roles"` //< ProjectRoleAssignment + ProjectSecurityRoles types.Set `tfsdk:"project_security_roles"` //< ProjectRoleAssignment +} + +type DeploymentRoleAssignment struct { + Role types.String `tfsdk:"role"` + ForAllDeployments types.Bool `tfsdk:"all_deployments"` + DeploymentIDs types.Set `tfsdk:"deployment_ids"` + ApplicationRoles types.Set `tfsdk:"application_roles"` +} + +type ProjectRoleAssignment struct { + Role types.String `tfsdk:"role"` + ForAllProjects types.Bool `tfsdk:"all_projects"` + ProjectIDs types.Set `tfsdk:"project_ids"` + ApplicationRoles types.Set `tfsdk:"application_roles"` +} + +func (r *Resource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: `Manages an Elastic Cloud organization membership. + + ~> **This resource can only be used with Elastic Cloud SaaS**`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "Organization ID", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "members": organizationMembersSchema(), + }, + } +} + +func organizationMembersSchema() schema.MapNestedAttribute { + return schema.MapNestedAttribute{ + MarkdownDescription: "Manages the members of an Elastic Cloud organization. The key of each entry should be the email of the member.", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "email": schema.StringAttribute{ + MarkdownDescription: "Email address of the user.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "invitation_pending": schema.BoolAttribute{ + MarkdownDescription: "Set to true while the user has not yet accepted their invitation to the organization.", + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "user_id": schema.StringAttribute{ + MarkdownDescription: "User ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "organization_role": schema.StringAttribute{ + MarkdownDescription: "The optional organization role for the member. Can be one of `organization-admin`, `billing-admin`. For more info see: [Organization roles](https://www.elastic.co/guide/en/cloud/current/ec-user-privileges.html#ec_organization_level_roles)", + Optional: true, + }, + "deployment_roles": deploymentRoleAssignmentsSchema(), + "project_elasticsearch_roles": projectElasticsearchRolesSchema(), + "project_observability_roles": projectObservabilityRolesSchema(), + "project_security_roles": projectSecurityRolesSchema(), + }, + }, + } +} + +func deploymentRoleAssignmentsSchema() schema.SetNestedAttribute { + return schema.SetNestedAttribute{ + MarkdownDescription: "Grant access to one or more deployments. For more info see: [Deployment instance roles](https://www.elastic.co/guide/en/cloud/current/ec-user-privileges.html#ec_instance_access_roles).", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "role": schema.StringAttribute{ + MarkdownDescription: "Assigned role. Must be on of `viewer`, `editor` or `admin`.", + Required: true, + }, + "all_deployments": schema.BoolAttribute{ + MarkdownDescription: "Role applies to all deployments in the organization.", + Optional: true, + PlanModifiers: []planmodifier.Bool{ + planmodifiers.BoolDefaultValue(false), // consider unknown as false + }, + }, + "deployment_ids": schema.SetAttribute{ + MarkdownDescription: "Role applies to deployments listed here.", + Optional: true, + ElementType: types.StringType, + }, + "application_roles": schema.SetAttribute{ + MarkdownDescription: "If provided, the user assigned this role assignment will be granted this application role when signing in to the deployment(s) specified in the role assignment.", + Optional: true, + ElementType: types.StringType, + }, + }, + }, + } +} + +func projectElasticsearchRolesSchema() schema.SetNestedAttribute { + elementSchema := projectRoleAssignmentSchema([]string{ + "admin", + "developer", + "viewer", + }) + return schema.SetNestedAttribute{ + MarkdownDescription: "Roles assigned for elasticsearch projects. For more info see: [Serverless elasticsearch roles](https://www.elastic.co/docs/current/serverless/general/assign-user-roles#es) ", + Optional: true, + Computed: true, + NestedObject: elementSchema, + PlanModifiers: []planmodifier.Set{ + planmodifiers.SetDefaultValue(elementSchema.Type(), nil), + }, + } +} + +func projectObservabilityRolesSchema() schema.SetNestedAttribute { + elementSchema := projectRoleAssignmentSchema([]string{ + "admin", + "editor", + "viewer", + }) + return schema.SetNestedAttribute{ + MarkdownDescription: "Roles assigned for observability projects. For more info see: [Serverless observability roles](https://www.elastic.co/docs/current/serverless/general/assign-user-roles#observability)", + Optional: true, + Computed: true, + NestedObject: elementSchema, + PlanModifiers: []planmodifier.Set{ + planmodifiers.SetDefaultValue(elementSchema.Type(), nil), + }, + } +} + +func projectSecurityRolesSchema() schema.SetNestedAttribute { + elementSchema := projectRoleAssignmentSchema([]string{ + "admin", + "editor", + "viewer", + "t1-analyst", + "t2-analyst", + "t3-analyst", + "threat-intel-analyst", + "rule-author", + "soc-manager", + "endpoint-operations-analyst", + "platform-engineer", + "detections-admin", + "endpoint-policy-manager", + }) + return schema.SetNestedAttribute{ + MarkdownDescription: "Roles assigned for security projects. For more info see: [Serverless security roles](https://www.elastic.co/docs/current/serverless/general/assign-user-roles#security)", + Optional: true, + Computed: true, + NestedObject: elementSchema, + PlanModifiers: []planmodifier.Set{ + planmodifiers.SetDefaultValue(elementSchema.Type(), []attr.Value{}), + }, + } +} + +func projectRoleAssignmentSchema(roles []string) schema.NestedAttributeObject { + return schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "role": schema.StringAttribute{ + MarkdownDescription: fmt.Sprintf("Assigned role. (Allowed values: %s)", "`"+strings.Join(roles, "`, `")+"`"), + Required: true, + }, + "all_projects": schema.BoolAttribute{ + MarkdownDescription: "Role applies to all projects in the organization.", + Optional: true, + PlanModifiers: []planmodifier.Bool{ + planmodifiers.BoolDefaultValue(false), // consider unknown as false + }, + }, + "project_ids": schema.SetAttribute{ + MarkdownDescription: "Role applies to projects listed here.", + Optional: true, + ElementType: types.StringType, + }, + "application_roles": schema.SetAttribute{ + MarkdownDescription: "If provided, the user assigned this role assignment will be granted this application role when signing in to the project(s) specified in the role assignment.", + Optional: true, + ElementType: types.StringType, + }, + }, + } +} diff --git a/ec/ecresource/organizationresource/update.go b/ec/ecresource/organizationresource/update.go new file mode 100644 index 000000000..9e286e55b --- /dev/null +++ b/ec/ecresource/organizationresource/update.go @@ -0,0 +1,268 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package organizationresource + +import ( + "context" + "fmt" + "github.com/elastic/cloud-sdk-go/pkg/api/organizationapi" + "github.com/elastic/cloud-sdk-go/pkg/models" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "sort" + "strings" +) + +func (r *Resource) Update(ctx context.Context, request resource.UpdateRequest, response *resource.UpdateResponse) { + diagnostics := &response.Diagnostics + + var plan Organization + var state Organization + diagnostics.Append(request.Plan.Get(ctx, &plan)...) + diagnostics.Append(request.State.Get(ctx, &state)...) + if diagnostics.HasError() { + return + } + + organizationID := plan.ID.ValueString() + + planMembers := make(map[string]types.Object) + diags := plan.Members.ElementsAs(ctx, &planMembers, false) + if diags.HasError() { + diagnostics.Append(diags...) + return + } + stateMembers := make(map[string]types.Object) + diags = state.Members.ElementsAs(ctx, &stateMembers, false) + if diags.HasError() { + diagnostics.Append(diags...) + return + } + + // Create new members, update changed members + for email, planMember := range planMembers { + planMemberModel := toModel(ctx, planMember, diagnostics) + if diagnostics.HasError() { + continue + } + + // create new invitation if member is in plan but not in state + stateMember, ok := stateMembers[email] + if !ok { + r.createInvitation(ctx, email, planMemberModel, organizationID, diagnostics) + } else { + // member is in plan and state, update if there is a diff + if !stateMember.Equal(planMember) { + stateMemberModel := toModel(ctx, stateMember, diagnostics) + if diagnostics.HasError() { + continue + } + r.updateMember(ctx, email, stateMemberModel, planMemberModel, organizationID, diagnostics) + } + } + } + + // Delete removed members + for email, stateMember := range stateMembers { + _, ok := planMembers[email] + if !ok { + // member is in state, but not in plan + stateMemberModel := toModel(ctx, stateMember, diagnostics) + if diagnostics.HasError() { + continue + } + r.deleteMember(email, stateMemberModel, organizationID, diagnostics) + } + } + + // Re-read the whole org from the API to get the current state + updatedOrganization := r.readFromApi(ctx, organizationID, diagnostics) + if diagnostics.HasError() { + return + } + diagnostics.Append(response.State.Set(ctx, *updatedOrganization)...) +} + +func (r *Resource) updateMember( + ctx context.Context, + email string, + stateMember OrganizationMember, + planMember OrganizationMember, + organizationID string, + diagnostics *diag.Diagnostics, +) { + if planMember.InvitationPending.ValueBool() { + // Invitations can't be updated, so while the invitation is pending the role assignments can't be changed + // The only way to update them is by creating a new invitation with the right role-assignments. + r.deleteInvitation(email, organizationID, diagnostics) + r.createInvitation(ctx, email, planMember, organizationID, diagnostics) + } else { + // Add new role assignments + planApiMember := modelToApi(ctx, planMember, organizationID, diagnostics) + if diagnostics.HasError() { + return + } + + stateApiMember := modelToApi(ctx, stateMember, organizationID, diagnostics) + if diagnostics.HasError() { + return + } + + add, remove := diffRoleAssignments(stateApiMember.RoleAssignments, planApiMember.RoleAssignments) + + if hasChanges(add) { + _, err := organizationapi.AddRoleAssignments(organizationapi.AddRoleAssignmentsParams{ + API: r.client, + UserID: planMember.UserID.ValueString(), + RoleAssignments: add, + }) + if err != nil { + diagnostics.Append(diag.NewErrorDiagnostic("Updating member roles failed.", err.Error())) + return + } + } + + if hasChanges(remove) { + _, err := organizationapi.RemoveRoleAssignments(organizationapi.RemoveRoleAssignmentsParams{ + API: r.client, + UserID: planMember.UserID.ValueString(), + RoleAssignments: remove, + }) + if err != nil { + diagnostics.Append(diag.NewErrorDiagnostic("Updating member roles failed.", err.Error())) + return + } + } + } +} + +func hasChanges(ra models.RoleAssignments) bool { + if len(ra.Organization) > 0 { + return true + } + if len(ra.Deployment) > 0 { + return true + } + if ra.Project != nil { + return len(ra.Project.Elasticsearch) > 0 || + len(ra.Project.Security) > 0 || + len(ra.Project.Observability) > 0 + } + return false +} + +func toModel(ctx context.Context, member types.Object, diags *diag.Diagnostics) OrganizationMember { + var modelValue OrganizationMember + var objectAsOptions = basetypes.ObjectAsOptions{UnhandledNullAsEmpty: false, UnhandledUnknownAsEmpty: false} + diags.Append(member.As(ctx, &modelValue, objectAsOptions)...) + return modelValue +} + +func diffRoleAssignments(old, new *models.RoleAssignments) (models.RoleAssignments, models.RoleAssignments) { + addOrganization, removeOrganization := diffOrganizationRoleAssignments( + old.Organization, + new.Organization, + ) + + addDeployment, removeDeployment := diffDeploymentRoleAssignments( + old.Deployment, + new.Deployment, + ) + + var addProject, removeProject *models.ProjectRoleAssignments + if old.Project != nil && new.Project != nil { + addProject, removeProject = diffProjectRoleAssignments( + *old.Project, + *new.Project, + ) + } else if old.Project == nil && new.Project != nil { + addProject, removeProject = new.Project, nil + } else if old.Project != nil && new.Project == nil { + addProject, removeProject = nil, old.Project + } + + add := models.RoleAssignments{ + Organization: addOrganization, + Deployment: addDeployment, + Project: addProject, + } + remove := models.RoleAssignments{ + Organization: removeOrganization, + Deployment: removeDeployment, + Project: removeProject, + } + + return add, remove +} + +func diffOrganizationRoleAssignments(old, new []*models.OrganizationRoleAssignment) ([]*models.OrganizationRoleAssignment, []*models.OrganizationRoleAssignment) { + getKey := func(ra models.OrganizationRoleAssignment) string { + return *ra.RoleID + } + add := difference(new, old, getKey) + remove := difference(old, new, getKey) + return add, remove +} + +func diffDeploymentRoleAssignments(old, new []*models.DeploymentRoleAssignment) ([]*models.DeploymentRoleAssignment, []*models.DeploymentRoleAssignment) { + getKey := func(ra models.DeploymentRoleAssignment) string { + var all bool + if ra.All != nil { + all = *ra.All + } + sort.Strings(ra.DeploymentIds) + return fmt.Sprintf("%s-%t-%s", *ra.RoleID, all, strings.Join(ra.DeploymentIds, ",")) + } + add := difference(new, old, getKey) + remove := difference(old, new, getKey) + return add, remove +} + +func diffProjectRoleAssignments(old, new models.ProjectRoleAssignments) (*models.ProjectRoleAssignments, *models.ProjectRoleAssignments) { + getKey := func(ra models.ProjectRoleAssignment) string { + var all bool + if ra.All != nil { + all = *ra.All + } + sort.Strings(ra.ProjectIds) + return fmt.Sprintf("%s-%t-%s", *ra.RoleID, all, strings.Join(ra.ProjectIds, ",")) + } + + addElasticsearch := difference(new.Elasticsearch, old.Elasticsearch, getKey) + removeElasticsearch := difference(old.Elasticsearch, new.Elasticsearch, getKey) + + addObservability := difference(new.Observability, old.Observability, getKey) + removeObservability := difference(old.Observability, new.Observability, getKey) + + addSecurity := difference(new.Security, old.Security, getKey) + removeSecurity := difference(old.Security, new.Security, getKey) + + add := models.ProjectRoleAssignments{ + Elasticsearch: addElasticsearch, + Observability: addObservability, + Security: addSecurity, + } + remove := models.ProjectRoleAssignments{ + Elasticsearch: removeElasticsearch, + Observability: removeObservability, + Security: removeSecurity, + } + return &add, &remove +} diff --git a/ec/provider.go b/ec/provider.go index 89420ce9f..0f932c062 100644 --- a/ec/provider.go +++ b/ec/provider.go @@ -20,6 +20,7 @@ package ec import ( "context" "fmt" + "github.com/elastic/terraform-provider-ec/ec/ecresource/organizationresource" "net/http" "time" @@ -121,6 +122,7 @@ func (p *Provider) Resources(ctx context.Context) []func() resource.Resource { func() resource.Resource { return projectresource.NewElasticsearchProjectResource() }, func() resource.Resource { return projectresource.NewObservabilityProjectResource() }, func() resource.Resource { return projectresource.NewSecurityProjectResource() }, + func() resource.Resource { return &organizationresource.Resource{} }, } } diff --git a/examples/resources/ec_organization/basic.tf b/examples/resources/ec_organization/basic.tf new file mode 100644 index 000000000..279191208 --- /dev/null +++ b/examples/resources/ec_organization/basic.tf @@ -0,0 +1,52 @@ +resource "ec_organization" "my_org" { + members = { + "a.member@example.com" = { + # All role definitions are optional + + # Define roles for the whole organization + # Available roles are documented here: https://www.elastic.co/guide/en/cloud/current/ec-user-privileges.html#ec_organization_level_roles + organization_role = "billing-admin" + + # Define deployment-specific roles + # Available roles are documented here: https://www.elastic.co/guide/en/cloud/current/ec-user-privileges.html#ec_instance_access_roles + deployment_roles = [ + # A role can be given for all deployments + { + role = "editor" + all_deployments = true + }, + + # Or just for specific deployments + { + role = "editor" + deployment_ids = ["ce03a623751b4fc98d48400fec58b9c0"] + } + ] + + # Define roles for elasticsearch projects (Docs: https://www.elastic.co/docs/current/serverless/general/assign-user-roles#es) + project_elasticsearch_roles = [ + # A role can be given for all projects + { + role = "admin" + all_projects = true + }, + + # Or just for specific projects + { + role = "admin" + project_ids = ["c866244b611442d585e23a0cc8c9434c"] + } + ] + + project_observability_roles = [ + # Same as for an elasticsearch project + # Available roles are documented here: https://www.elastic.co/docs/current/serverless/general/assign-user-roles#observability + ] + + project_security_roles = [ + # Same as for an elasticsearch project + # Available roles are documented here: https://www.elastic.co/docs/current/serverless/general/assign-user-roles#security + ] + } + } +} diff --git a/examples/resources/ec_organization/locals.tf b/examples/resources/ec_organization/locals.tf new file mode 100644 index 000000000..eff4984c9 --- /dev/null +++ b/examples/resources/ec_organization/locals.tf @@ -0,0 +1,28 @@ +# To simplify managing multiple members with the same roles, the roles can be assigned to local variables +locals { + deployment_admin = { + deployment_roles = [ + { + role = "admin" + all_deployments = true + } + ] + } + + deployment_viewer = { + deployment_roles = [ + { + role = "viewer" + all_deployments = true + } + ] + } +} + +resource "ec_organization" "my_org" { + members = { + "admin@example.com" = local.deployment_admin + "viewer@example.com" = local.deployment_viewer + "another.viewer@example.com" = local.deployment_viewer + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index 03ca31fa4..0f364670c 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/blang/semver v3.5.1+incompatible github.com/blang/semver/v4 v4.0.0 github.com/deepmap/oapi-codegen/v2 v2.1.0 - github.com/elastic/cloud-sdk-go v1.20.0 + github.com/elastic/cloud-sdk-go v1.21.0 github.com/go-openapi/runtime v0.28.0 github.com/go-openapi/strfmt v0.23.0 github.com/hashicorp/terraform-plugin-codegen-framework v0.4.0 diff --git a/go.sum b/go.sum index 279841e29..bd8cea0f9 100644 --- a/go.sum +++ b/go.sum @@ -53,8 +53,8 @@ github.com/deepmap/oapi-codegen/v2 v2.1.0/go.mod h1:R1wL226vc5VmCNJUvMyYr3hJMm5r github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= -github.com/elastic/cloud-sdk-go v1.20.0 h1:OkGG0CRXSZbntNwoKATbqO8SQoaBZAfAcXavdi8sA5Y= -github.com/elastic/cloud-sdk-go v1.20.0/go.mod h1:k0ZebhZKX22l6Ysl5Zbpc8VLF54hfwDtHppEEEVUJ04= +github.com/elastic/cloud-sdk-go v1.21.0 h1:nP+ca4nU026kdkzEVcqwEHJbv0sGlIGgRoROPu+YjMU= +github.com/elastic/cloud-sdk-go v1.21.0/go.mod h1:k0ZebhZKX22l6Ysl5Zbpc8VLF54hfwDtHppEEEVUJ04= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= diff --git a/templates/resources/organization.md.tmpl b/templates/resources/organization.md.tmpl new file mode 100644 index 000000000..876c4d9d5 --- /dev/null +++ b/templates/resources/organization.md.tmpl @@ -0,0 +1,34 @@ +--- +page_title: "Elastic Cloud: {{ .Name }} {{ .Type }}" +description: |- + {{ .Description }} +--- + +# {{ .Type }}: {{ .Name }} + +{{ .Description }} + +## Example Usage + +### Import + +To import an organization into terraform, first define your organization configuration in your terraform file. For example: +```terraform +resource "ec_organization" "myorg" { +} +``` + +Then import the organization using your organization-id (The organization id can be found on [the organization page](https://cloud.elastic.co/account/members)) +```bash +terraform import ec_organization.myorg +``` + +Now you can run `terraform plan` to see if there are any diffs between your config and how your organization is currently configured. + +### Basic + +{{ tffile "examples/resources/ec_organization/basic.tf" }} + +### Use variables to give the same roles to multiple users + +{{ tffile "examples/resources/ec_organization/locals.tf" }} \ No newline at end of file