From 31ce9ab36db7796102e75bae6458f337b888d5a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Palet?= Date: Mon, 29 Jul 2024 09:57:06 +0100 Subject: [PATCH] Allow managing members in the project resource (#480) * Extend resource and datasource * Adapt acc test to work without members * Extend acc test and adjust resource * Generate docs * Fix lint * Fix unit test * Uniformize description with datasource and extend unit test * Improve role field description * Update TF state before adding/removing members * Remove unused function * Move intermediate map top state to mapProjectFields * Improve code --- docs/data-sources/resourcemanager_project.md | 12 + docs/index.md | 1 + docs/resources/resourcemanager_project.md | 13 +- go.mod | 1 + go.sum | 2 + stackit/internal/core/core.go | 1 + .../resourcemanager/project/datasource.go | 162 +++--- .../resourcemanager/project/resource.go | 429 ++++++++++++-- .../resourcemanager/project/resource_test.go | 537 +++++++++++++++++- .../resourcemanager_acc_test.go | 54 +- stackit/internal/testutil/testutil.go | 7 +- stackit/internal/utils/utils.go | 13 +- stackit/internal/utils/utils_test.go | 43 ++ stackit/internal/validate/validate.go | 18 + stackit/internal/validate/validate_test.go | 54 ++ stackit/provider.go | 17 +- 16 files changed, 1195 insertions(+), 169 deletions(-) diff --git a/docs/data-sources/resourcemanager_project.md b/docs/data-sources/resourcemanager_project.md index d8a35809..a1898fd1 100644 --- a/docs/data-sources/resourcemanager_project.md +++ b/docs/data-sources/resourcemanager_project.md @@ -25,11 +25,23 @@ data "stackit_resourcemanager_project" "example" { ### Optional - `container_id` (String) Project container ID. Globally unique, user-friendly identifier. +- `owner_email` (String, Deprecated) Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect. + +!> The "owner_email" field has been deprecated in favor of the "members" field. Please use the "members" field to assign the owner role to a user, by setting the "role" field to `owner`. - `project_id` (String) Project UUID identifier. This is the ID that can be used in most of the other resources to identify the project. ### Read-Only - `id` (String) Terraform's internal data source. ID. It is structured as "`container_id`". - `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64} +- `members` (Attributes List) The members assigned to the project. At least one subject needs to be a user, and not a client or service account. (see [below for nested schema](#nestedatt--members)) - `name` (String) Project name. - `parent_container_id` (String) Parent resource identifier. Both container ID (user-friendly) and UUID are supported + + +### Nested Schema for `members` + +Read-Only: + +- `role` (String) The role of the member in the project. At least one user must have the `owner` role. Legacy roles (`project.admin`, `project.auditor`, `project.member`, `project.owner`) are not supported. +- `subject` (String) Unique identifier of the user, service account or client. This is usually the email address for users or service accounts, and the name in case of clients. diff --git a/docs/index.md b/docs/index.md index 4b0a9bc9..c3d0f46a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -141,6 +141,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de ### Optional - `argus_custom_endpoint` (String) Custom endpoint for the Argus service +- `authorization_custom_endpoint` (String) Custom endpoint for the Membership service - `credentials_path` (String) Path of JSON from where the credentials are read. Takes precedence over the env var `STACKIT_CREDENTIALS_PATH`. Default value is `~/.stackit/credentials.json`. - `dns_custom_endpoint` (String) Custom endpoint for the DNS service - `enable_beta_resources` (Boolean) Enable beta resources. Default is false. diff --git a/docs/resources/resourcemanager_project.md b/docs/resources/resourcemanager_project.md index 92d3abe6..6f0bee0b 100644 --- a/docs/resources/resourcemanager_project.md +++ b/docs/resources/resourcemanager_project.md @@ -29,15 +29,26 @@ resource "stackit_resourcemanager_project" "example" { ### Required - `name` (String) Project name. -- `owner_email` (String) Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect. - `parent_container_id` (String) Parent resource identifier. Both container ID (user-friendly) and UUID are supported ### Optional - `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64} +- `members` (Attributes List) The members assigned to the project. At least one subject needs to be a user, and not a client or service account. (see [below for nested schema](#nestedatt--members)) +- `owner_email` (String, Deprecated) Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect. + +!> The "owner_email" field has been deprecated in favor of the "members" field. Please use the "members" field to assign the owner role to a user, by setting the "role" field to `owner`. ### Read-Only - `container_id` (String) Project container ID. Globally unique, user-friendly identifier. - `id` (String) Terraform's internal resource ID. It is structured as "`container_id`". - `project_id` (String) Project UUID identifier. This is the ID that can be used in most of the other resources to identify the project. + + +### Nested Schema for `members` + +Required: + +- `role` (String) The role of the member in the project. At least one user must have the `owner` role. Legacy roles (`project.admin`, `project.auditor`, `project.member`, `project.owner`) are not supported. +- `subject` (String) Unique identifier of the user, service account or client. This is usually the email address for users or service accounts, and the name in case of clients. diff --git a/go.mod b/go.mod index 8835a748..7c4e4684 100644 --- a/go.mod +++ b/go.mod @@ -71,6 +71,7 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/oklog/run v1.1.0 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/stackitcloud/stackit-sdk-go/services/authorization v0.3.0 github.com/stretchr/testify v1.8.4 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect diff --git a/go.sum b/go.sum index 93960fd4..92ad123f 100644 --- a/go.sum +++ b/go.sum @@ -148,6 +148,8 @@ github.com/stackitcloud/stackit-sdk-go/core v0.12.0 h1:auIzUUNRuydKOScvpICP4MifG github.com/stackitcloud/stackit-sdk-go/core v0.12.0/go.mod h1:mDX1mSTsB3mP+tNBGcFNx6gH1mGBN4T+dVt+lcw7nlw= github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0 h1:JVEx/ouHB6PlwGzQa3ywyDym1HTWo3WgrxAyXprCnuM= github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0/go.mod h1:nVllQfYODhX1q3bgwVTLO7wHOp+8NMLiKbn3u/Dg5nU= +github.com/stackitcloud/stackit-sdk-go/services/authorization v0.3.0 h1:AyzBgcbd0rCm+2+xaWqtfibjWmkKlO+U+7qxqvtKpJ8= +github.com/stackitcloud/stackit-sdk-go/services/authorization v0.3.0/go.mod h1:1sLuXa7Qvp9f+wKWdRjyNe8B2F8JX7nSTd8fBKadri4= github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0 h1:QIZfs6nJ/l2pOweH1E+wazXnlAUtqisVbYUxWAokTbc= github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0/go.mod h1:MdZcRbs19s2NLeJmSLSoqTzm9IPIQhE1ZEMpo9gePq0= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.4.0 h1:W6Zxyq487RpWfEIb6GL7tGTt5SsBzxHPeYTzmB11GtY= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index 35598214..b13ac3d1 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -18,6 +18,7 @@ type ProviderData struct { ServiceAccountEmail string Region string ArgusCustomEndpoint string + AuthorizationCustomEndpoint string DnsCustomEndpoint string IaaSCustomEndpoint string LoadBalancerCustomEndpoint string diff --git a/stackit/internal/services/resourcemanager/project/datasource.go b/stackit/internal/services/resourcemanager/project/datasource.go index 2fda5c76..bb07b9b7 100644 --- a/stackit/internal/services/resourcemanager/project/datasource.go +++ b/stackit/internal/services/resourcemanager/project/datasource.go @@ -5,14 +5,14 @@ import ( "fmt" "net/http" "regexp" + "strings" "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework/types/basetypes" - "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" "github.com/hashicorp/terraform-plugin-framework/datasource" @@ -20,6 +20,7 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/authorization" "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" ) @@ -28,15 +29,6 @@ var ( _ datasource.DataSource = &projectDataSource{} ) -type ModelData struct { - Id types.String `tfsdk:"id"` // needed by TF - ProjectId types.String `tfsdk:"project_id"` - ContainerId types.String `tfsdk:"container_id"` - ContainerParentId types.String `tfsdk:"parent_container_id"` - Name types.String `tfsdk:"name"` - Labels types.Map `tfsdk:"labels"` -} - // NewProjectDataSource is a helper function to simplify the provider implementation. func NewProjectDataSource() datasource.DataSource { return &projectDataSource{} @@ -44,7 +36,8 @@ func NewProjectDataSource() datasource.DataSource { // projectDataSource is the data source implementation. type projectDataSource struct { - client *resourcemanager.APIClient + resourceManagerClient *resourcemanager.APIClient + membershipClient *authorization.APIClient } // Metadata returns the data source type name. @@ -58,9 +51,8 @@ func (d *projectDataSource) Configure(ctx context.Context, req datasource.Config return } - var apiClient *resourcemanager.APIClient + var rmClient *resourcemanager.APIClient var err error - providerData, ok := req.ProviderData.(core.ProviderData) if !ok { core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) @@ -68,13 +60,13 @@ func (d *projectDataSource) Configure(ctx context.Context, req datasource.Config } if providerData.ResourceManagerCustomEndpoint != "" { - apiClient, err = resourcemanager.NewAPIClient( + rmClient, err = resourcemanager.NewAPIClient( config.WithCustomAuth(providerData.RoundTripper), config.WithServiceAccountEmail(providerData.ServiceAccountEmail), config.WithEndpoint(providerData.ResourceManagerCustomEndpoint), ) } else { - apiClient, err = resourcemanager.NewAPIClient( + rmClient, err = resourcemanager.NewAPIClient( config.WithCustomAuth(providerData.RoundTripper), config.WithServiceAccountEmail(providerData.ServiceAccountEmail), ) @@ -84,20 +76,44 @@ func (d *projectDataSource) Configure(ctx context.Context, req datasource.Config return } - d.client = apiClient + var aClient *authorization.APIClient + if providerData.AuthorizationCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "authorization_custom_endpoint", providerData.AuthorizationCustomEndpoint) + aClient, err = authorization.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.AuthorizationCustomEndpoint), + ) + } else { + aClient, err = authorization.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring Membership API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return + } + + d.resourceManagerClient = rmClient + d.membershipClient = aClient tflog.Info(ctx, "Resource Manager project client configured") } // Schema defines the schema for the data source. func (d *projectDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { descriptions := map[string]string{ - "main": "Resource Manager project data source schema. To identify the project, you need to provider either project_id or container_id. If you provide both, project_id will be used.", - "id": "Terraform's internal data source. ID. It is structured as \"`container_id`\".", - "project_id": "Project UUID identifier. This is the ID that can be used in most of the other resources to identify the project.", - "container_id": "Project container ID. Globally unique, user-friendly identifier.", - "parent_container_id": "Parent resource identifier. Both container ID (user-friendly) and UUID are supported", - "name": "Project name.", - "labels": `Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`, + "main": "Resource Manager project data source schema. To identify the project, you need to provider either project_id or container_id. If you provide both, project_id will be used.", + "id": "Terraform's internal data source. ID. It is structured as \"`container_id`\".", + "project_id": "Project UUID identifier. This is the ID that can be used in most of the other resources to identify the project.", + "container_id": "Project container ID. Globally unique, user-friendly identifier.", + "parent_container_id": "Parent resource identifier. Both container ID (user-friendly) and UUID are supported", + "name": "Project name.", + "labels": `Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`, + "owner_email": "Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect.", + "owner_email_deprecation_message": "The \"owner_email\" field has been deprecated in favor of the \"members\" field. Please use the \"members\" field to assign the owner role to a user, by setting the \"role\" field to `owner`.", + "members": "The members assigned to the project. At least one subject needs to be a user, and not a client or service account.", + "members.role": fmt.Sprintf("The role of the member in the project. At least one user must have the `owner` role. Legacy roles (%s) are not supported.", strings.Join(utils.QuoteValues(utils.LegacyProjectRoles), ", ")), + "members.subject": "Unique identifier of the user, service account or client. This is usually the email address for users or service accounts, and the name in case of clients.", } resp.Schema = schema.Schema{ @@ -153,23 +169,48 @@ func (d *projectDataSource) Schema(_ context.Context, _ datasource.SchemaRequest ), }, }, + "owner_email": schema.StringAttribute{ + Description: descriptions["owner_email"], + DeprecationMessage: descriptions["owner_email_deprecation_message"], + MarkdownDescription: fmt.Sprintf("%s\n\n!> %s", descriptions["owner_email"], descriptions["owner_email_deprecation_message"]), + Optional: true, + }, + "members": schema.ListNestedAttribute{ + Description: descriptions["members"], + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "role": schema.StringAttribute{ + Description: descriptions["members.role"], + Computed: true, + Validators: []validator.String{ + validate.NonLegacyProjectRole(), + }, + }, + "subject": schema.StringAttribute{ + Description: descriptions["members.subject"], + Computed: true, + }, + }, + }, + }, }, } } // Read refreshes the Terraform state with the latest data. func (d *projectDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform - var state ModelData - diags := req.Config.Get(ctx, &state) + var model Model + diags := req.Config.Get(ctx, &model) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - projectId := state.ProjectId.ValueString() + projectId := model.ProjectId.ValueString() ctx = tflog.SetField(ctx, "project_id", projectId) - containerId := state.ContainerId.ValueString() + containerId := model.ContainerId.ValueString() ctx = tflog.SetField(ctx, "container_id", containerId) if containerId == "" && projectId == "" { @@ -183,7 +224,7 @@ func (d *projectDataSource) Read(ctx context.Context, req datasource.ReadRequest identifier = projectId } - projectResp, err := d.client.GetProject(ctx, identifier).Execute() + projectResp, err := d.resourceManagerClient.GetProject(ctx, identifier).Execute() if err != nil { oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped if ok && oapiErr.StatusCode == http.StatusForbidden { @@ -193,60 +234,27 @@ func (d *projectDataSource) Read(ctx context.Context, req datasource.ReadRequest return } - err = mapDataFields(ctx, projectResp, &state) + err = mapProjectFields(ctx, projectResp, &model, &resp.State) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Processing API payload: %v", err)) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Processing API response: %v", err)) return } - diags = resp.State.Set(ctx, &state) + + membersResp, err := d.membershipClient.ListMembersExecute(ctx, projectResourceType, *projectResp.ProjectId) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Reading members: %v", err)) + return + } + + err = mapMembersFields(membersResp.Members, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Processing API response: %v", err)) + return + } + diags = resp.State.Set(ctx, &model) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } tflog.Info(ctx, "Resource Manager project read") } - -func mapDataFields(ctx context.Context, projectResp *resourcemanager.GetProjectResponse, model *ModelData) (err error) { - if projectResp == nil { - return fmt.Errorf("response input is nil") - } - if model == nil { - return fmt.Errorf("model input is nil") - } - - var projectId string - if model.ProjectId.ValueString() != "" { - projectId = model.ProjectId.ValueString() - } else if projectResp.ProjectId != nil { - projectId = *projectResp.ProjectId - } else { - return fmt.Errorf("project id not present") - } - - var containerId string - if model.ContainerId.ValueString() != "" { - containerId = model.ContainerId.ValueString() - } else if projectResp.ContainerId != nil { - containerId = *projectResp.ContainerId - } else { - return fmt.Errorf("container id not present") - } - - var labels basetypes.MapValue - if projectResp.Labels != nil { - labels, err = conversion.ToTerraformStringMap(ctx, *projectResp.Labels) - if err != nil { - return fmt.Errorf("converting to StringValue map: %w", err) - } - } else { - labels = types.MapNull(types.StringType) - } - - model.Id = types.StringValue(containerId) - model.ProjectId = types.StringValue(projectId) - model.ContainerId = types.StringValue(containerId) - model.ContainerParentId = types.StringPointerValue(projectResp.Parent.ContainerId) - model.Name = types.StringPointerValue(projectResp.Name) - model.Labels = labels - return nil -} diff --git a/stackit/internal/services/resourcemanager/project/resource.go b/stackit/internal/services/resourcemanager/project/resource.go index 68febc61..7c6bd1de 100644 --- a/stackit/internal/services/resourcemanager/project/resource.go +++ b/stackit/internal/services/resourcemanager/project/resource.go @@ -9,11 +9,16 @@ import ( "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" "github.com/hashicorp/terraform-plugin-framework/path" @@ -25,6 +30,8 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/authorization" "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager/wait" ) @@ -37,7 +44,8 @@ var ( ) const ( - projectOwner = "project.owner" + projectResourceType = "project" + projectOwnerRole = "owner" ) type Model struct { @@ -48,6 +56,19 @@ type Model struct { Name types.String `tfsdk:"name"` Labels types.Map `tfsdk:"labels"` OwnerEmail types.String `tfsdk:"owner_email"` + Members types.List `tfsdk:"members"` +} + +// Struct corresponding to Model.Members[i] +type member struct { + Role types.String `tfsdk:"role"` + Subject types.String `tfsdk:"subject"` +} + +// Types corresponding to member +var memberTypes = map[string]attr.Type{ + "role": types.StringType, + "subject": types.StringType, } // NewProjectResource is a helper function to simplify the provider implementation. @@ -57,7 +78,8 @@ func NewProjectResource() resource.Resource { // projectResource is the resource implementation. type projectResource struct { - client *resourcemanager.APIClient + resourceManagerClient *resourcemanager.APIClient + authorizationClient *authorization.APIClient } // Metadata returns the resource type name. @@ -78,42 +100,65 @@ func (r *projectResource) Configure(ctx context.Context, req resource.ConfigureR return } - var apiClient *resourcemanager.APIClient + var rmClient *resourcemanager.APIClient var err error if providerData.ResourceManagerCustomEndpoint != "" { ctx = tflog.SetField(ctx, "resourcemanager_custom_endpoint", providerData.ResourceManagerCustomEndpoint) - apiClient, err = resourcemanager.NewAPIClient( + rmClient, err = resourcemanager.NewAPIClient( config.WithCustomAuth(providerData.RoundTripper), config.WithServiceAccountEmail(providerData.ServiceAccountEmail), config.WithEndpoint(providerData.ResourceManagerCustomEndpoint), ) } else { - apiClient, err = resourcemanager.NewAPIClient( + rmClient, err = resourcemanager.NewAPIClient( config.WithCustomAuth(providerData.RoundTripper), config.WithServiceAccountEmail(providerData.ServiceAccountEmail), ) } if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring Resource Manager API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) return } - r.client = apiClient + var aClient *authorization.APIClient + if providerData.AuthorizationCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "authorization_custom_endpoint", providerData.AuthorizationCustomEndpoint) + aClient, err = authorization.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.AuthorizationCustomEndpoint), + ) + } else { + aClient, err = authorization.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring Membership API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return + } + + r.resourceManagerClient = rmClient + r.authorizationClient = aClient tflog.Info(ctx, "Resource Manager project client configured") } // Schema defines the schema for the resource. func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { descriptions := map[string]string{ - "main": "Resource Manager project resource schema. To use this resource, it is required that you set the service account email in the provider configuration.", - "id": "Terraform's internal resource ID. It is structured as \"`container_id`\".", - "project_id": "Project UUID identifier. This is the ID that can be used in most of the other resources to identify the project.", - "container_id": "Project container ID. Globally unique, user-friendly identifier.", - "parent_container_id": "Parent resource identifier. Both container ID (user-friendly) and UUID are supported", - "name": "Project name.", - "labels": "Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}", - "owner_email": "Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect.", + "main": "Resource Manager project resource schema. To use this resource, it is required that you set the service account email in the provider configuration.", + "id": "Terraform's internal resource ID. It is structured as \"`container_id`\".", + "project_id": "Project UUID identifier. This is the ID that can be used in most of the other resources to identify the project.", + "container_id": "Project container ID. Globally unique, user-friendly identifier.", + "parent_container_id": "Parent resource identifier. Both container ID (user-friendly) and UUID are supported", + "name": "Project name.", + "labels": "Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}", + "owner_email": "Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect.", + "owner_email_deprecation_message": "The \"owner_email\" field has been deprecated in favor of the \"members\" field. Please use the \"members\" field to assign the owner role to a user, by setting the \"role\" field to `owner`.", + "members": "The members assigned to the project. At least one subject needs to be a user, and not a client or service account.", + "members.role": fmt.Sprintf("The role of the member in the project. At least one user must have the `owner` role. Legacy roles (%s) are not supported.", strings.Join(utils.QuoteValues(utils.LegacyProjectRoles), ", ")), + "members.subject": "Unique identifier of the user, service account or client. This is usually the email address for users or service accounts, and the name in case of clients.", } resp.Schema = schema.Schema{ @@ -179,13 +224,46 @@ func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, re }, }, "owner_email": schema.StringAttribute{ - Description: descriptions["owner_email"], - Required: true, + Description: descriptions["owner_email"], + DeprecationMessage: descriptions["owner_email_deprecation_message"], + MarkdownDescription: fmt.Sprintf("%s\n\n!> %s", descriptions["owner_email"], descriptions["owner_email_deprecation_message"]), + // When removing the owner_email field, we should mark the members field as required and add a listvalidator.SizeAtLeast(1) validator to it + Optional: true, + }, + "members": schema.ListNestedAttribute{ + Description: descriptions["members"], + Optional: true, + // Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "role": schema.StringAttribute{ + Description: descriptions["members.role"], + Required: true, + Validators: []validator.String{ + validate.NonLegacyProjectRole(), + }, + }, + "subject": schema.StringAttribute{ + Description: descriptions["members.subject"], + Required: true, + }, + }, + }, }, }, } } +// ConfigValidators validates the resource configuration +func (r *projectResource) ConfigValidators(_ context.Context) []resource.ConfigValidator { + return []resource.ConfigValidator{ + resourcevalidator.AtLeastOneOf( + path.MatchRoot("owner_email"), + path.MatchRoot("members"), + ), + } +} + // Create creates the resource and sets the initial Terraform state. func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform var model Model @@ -198,20 +276,20 @@ func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest containerId := model.ContainerId.ValueString() ctx = tflog.SetField(ctx, "project_container_id", containerId) - serviceAccountEmail := r.client.GetConfig().ServiceAccountEmail + serviceAccountEmail := r.resourceManagerClient.GetConfig().ServiceAccountEmail if serviceAccountEmail == "" { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating project", "The service account e-mail cannot be empty: set it in the provider configuration or through the STACKIT_SERVICE_ACCOUNT_EMAIL or in your credentials file (default filepath is ~/.stackit/credentials.json)") return } // Generate API request body from model - payload, err := toCreatePayload(&model, serviceAccountEmail) + payload, err := toCreatePayload(ctx, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating project", fmt.Sprintf("Creating API payload: %v", err)) return } // Create new project - createResp, err := r.client.CreateProject(ctx).CreateProjectPayload(*payload).Execute() + createResp, err := r.resourceManagerClient.CreateProject(ctx).CreateProjectPayload(*payload).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating project", fmt.Sprintf("Calling API: %v", err)) return @@ -220,14 +298,25 @@ func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest // If the request has not been processed yet and the containerId doesnt exist, // the waiter will fail with authentication error, so wait some time before checking the creation - waitResp, err := wait.CreateProjectWaitHandler(ctx, r.client, respContainerId).WaitWithContext(ctx) + waitResp, err := wait.CreateProjectWaitHandler(ctx, r.resourceManagerClient, respContainerId).WaitWithContext(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating project", fmt.Sprintf("Instance creation waiting: %v", err)) return } - // Map response body to schema - err = mapFields(ctx, waitResp, &model) + err = mapProjectFields(ctx, waitResp, &model, &resp.State) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating project", fmt.Sprintf("Processing API response: %v", err)) + return + } + + membersResp, err := r.authorizationClient.ListMembersExecute(ctx, projectResourceType, *waitResp.ProjectId) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating project", fmt.Sprintf("Reading members: %v", err)) + return + } + + err = mapMembersFields(membersResp.Members, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating project", fmt.Sprintf("Processing API payload: %v", err)) return @@ -252,7 +341,7 @@ func (r *projectResource) Read(ctx context.Context, req resource.ReadRequest, re containerId := model.ContainerId.ValueString() ctx = tflog.SetField(ctx, "container_id", containerId) - projectResp, err := r.client.GetProject(ctx, containerId).Execute() + projectResp, err := r.resourceManagerClient.GetProject(ctx, containerId).Execute() if err != nil { oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped if ok && oapiErr.StatusCode == http.StatusForbidden { @@ -263,10 +352,21 @@ func (r *projectResource) Read(ctx context.Context, req resource.ReadRequest, re return } - // Map response body to schema - err = mapFields(ctx, projectResp, &model) + err = mapProjectFields(ctx, projectResp, &model, &resp.State) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Processing API payload: %v", err)) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Processing API response: %v", err)) + return + } + + membersResp, err := r.authorizationClient.ListMembersExecute(ctx, projectResourceType, *projectResp.ProjectId) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Reading members: %v", err)) + return + } + + err = mapMembersFields(membersResp.Members, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Processing API response: %v", err)) return } // Set refreshed model @@ -297,21 +397,40 @@ func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest return } // Update existing project - _, err = r.client.PartialUpdateProject(ctx, containerId).PartialUpdateProjectPayload(*payload).Execute() + _, err = r.resourceManagerClient.PartialUpdateProject(ctx, containerId).PartialUpdateProjectPayload(*payload).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating project", fmt.Sprintf("Calling API: %v", err)) return } - // Fetch updated zone - projectResp, err := r.client.GetProject(ctx, containerId).Execute() + // Fetch updated project + projectResp, err := r.resourceManagerClient.GetProject(ctx, containerId).Execute() if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Calling API for updated data: %v", err)) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating project", fmt.Sprintf("Calling API for updated data: %v", err)) return } - err = mapFields(ctx, projectResp, &model) + + err = mapProjectFields(ctx, projectResp, &model, &resp.State) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Processing API payload: %v", err)) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating project", fmt.Sprintf("Processing API response: %v", err)) + return + } + + members, err := toMembersPayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating project", fmt.Sprintf("Processing members: %v", err)) + return + } + + err = updateMembers(ctx, *projectResp.ProjectId, members, r.authorizationClient) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating project", fmt.Sprintf("Updating members: %v", err)) + return + } + + err = mapMembersFields(members, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating project", fmt.Sprintf("Processing API response: %v", err)) return } diags = resp.State.Set(ctx, model) @@ -336,13 +455,13 @@ func (r *projectResource) Delete(ctx context.Context, req resource.DeleteRequest ctx = tflog.SetField(ctx, "container_id", containerId) // Delete existing project - err := r.client.DeleteProject(ctx, containerId).Execute() + err := r.resourceManagerClient.DeleteProject(ctx, containerId).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting project", fmt.Sprintf("Calling API: %v", err)) return } - _, err = wait.DeleteProjectWaitHandler(ctx, r.client, containerId).WaitWithContext(ctx) + _, err = wait.DeleteProjectWaitHandler(ctx, r.resourceManagerClient, containerId).WaitWithContext(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting project", fmt.Sprintf("Instance deletion waiting: %v", err)) return @@ -369,7 +488,8 @@ func (r *projectResource) ImportState(ctx context.Context, req resource.ImportSt tflog.Info(ctx, "Resource Manager Project state imported") } -func mapFields(ctx context.Context, projectResp *resourcemanager.GetProjectResponse, model *Model) (err error) { +// mapProjectFields maps the API response to the Terraform model and update the Terraform state +func mapProjectFields(ctx context.Context, projectResp *resourcemanager.GetProjectResponse, model *Model, state *tfsdk.State) (err error) { if projectResp == nil { return fmt.Errorf("response input is nil") } @@ -405,58 +525,150 @@ func mapFields(ctx context.Context, projectResp *resourcemanager.GetProjectRespo labels = types.MapNull(types.StringType) } - model.Id = types.StringValue(containerId) - model.ProjectId = types.StringValue(projectId) - model.ContainerId = types.StringValue(containerId) + var containerParentIdTF basetypes.StringValue if projectResp.Parent != nil { if _, err := uuid.Parse(model.ContainerParentId.ValueString()); err == nil { // the provided containerParentId is the UUID identifier - model.ContainerParentId = types.StringPointerValue(projectResp.Parent.Id) + containerParentIdTF = types.StringPointerValue(projectResp.Parent.Id) } else { // the provided containerParentId is the user-friendly container id - model.ContainerParentId = types.StringPointerValue(projectResp.Parent.ContainerId) + containerParentIdTF = types.StringPointerValue(projectResp.Parent.ContainerId) } } else { - model.ContainerParentId = types.StringNull() + containerParentIdTF = types.StringNull() } + + model.Id = types.StringValue(containerId) + model.ProjectId = types.StringValue(projectId) + model.ContainerParentId = containerParentIdTF + model.ContainerId = types.StringValue(containerId) model.Name = types.StringPointerValue(projectResp.Name) model.Labels = labels + + if state != nil { + diags := diag.Diagnostics{} + diags.Append(state.SetAttribute(ctx, path.Root("id"), model.Id)...) + diags.Append(state.SetAttribute(ctx, path.Root("project_id"), model.ProjectId)...) + diags.Append(state.SetAttribute(ctx, path.Root("parent_container_id"), model.ContainerParentId)...) + diags.Append(state.SetAttribute(ctx, path.Root("container_id"), model.ContainerId)...) + diags.Append(state.SetAttribute(ctx, path.Root("name"), model.Name)...) + diags.Append(state.SetAttribute(ctx, path.Root("labels"), model.Labels)...) + if diags.HasError() { + return fmt.Errorf("update terraform state: %w", core.DiagsToError(diags)) + } + } + return nil } -func toCreatePayload(model *Model, serviceAccountEmail string) (*resourcemanager.CreateProjectPayload, error) { +func mapMembersFields(members *[]authorization.Member, model *Model) error { + if members == nil { + model.Members = types.ListNull(types.ObjectType{AttrTypes: memberTypes}) + return nil + } + + if (model.Members.IsNull() || model.Members.IsUnknown()) && !model.OwnerEmail.IsNull() { + // If the new "members" field is not set and the deprecated "owner_email" field is set, + // we keep the old behavior and do map the members to avoid an inconsistent result after apply error + model.Members = types.ListNull(types.ObjectType{AttrTypes: memberTypes}) + return nil + } + + membersList := []attr.Value{} + for i, m := range *members { + if utils.IsLegacyProjectRole(*m.Role) { + continue + } + membersMap := map[string]attr.Value{ + "subject": types.StringPointerValue(m.Subject), + "role": types.StringPointerValue(m.Role), + } + + memberTF, diags := types.ObjectValue(memberTypes, membersMap) + if diags.HasError() { + return fmt.Errorf("mapping index %d: %w", i, core.DiagsToError(diags)) + } + + membersList = append(membersList, memberTF) + } + + membersTF, diags := types.ListValue( + types.ObjectType{AttrTypes: memberTypes}, + membersList, + ) + if diags.HasError() { + return core.DiagsToError(diags) + } + + model.Members = membersTF + return nil +} + +func toMembersPayload(ctx context.Context, model *Model) (*[]authorization.Member, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + if model.Members.IsNull() || model.Members.IsUnknown() { + return &[]authorization.Member{}, nil + } + + membersModel := []member{} + diags := model.Members.ElementsAs(ctx, &membersModel, false) + if diags.HasError() { + return nil, core.DiagsToError(diags) + } + + members := []authorization.Member{} + // If the new "members" fields is set, it has precedence over the deprecated "owner_email" field + if !model.Members.IsNull() && !model.Members.IsUnknown() { + for _, m := range membersModel { + members = append(members, authorization.Member{ + Role: m.Role.ValueStringPointer(), + Subject: m.Subject.ValueStringPointer(), + }) + } + } else { + members = append(members, authorization.Member{ + Subject: model.OwnerEmail.ValueStringPointer(), + Role: sdkUtils.Ptr(projectOwnerRole), + }) + } + + return &members, nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*resourcemanager.CreateProjectPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } - owner := projectOwner - serviceAccountSubject := serviceAccountEmail - members := []resourcemanager.Member{ - { - Subject: &serviceAccountSubject, - Role: &owner, - }, + members, err := toMembersPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("processing members: %w", err) } - - ownerSubject := model.OwnerEmail.ValueString() - if ownerSubject != "" && ownerSubject != serviceAccountSubject { - members = append(members, + var convertedMembers []resourcemanager.Member + for _, m := range *members { + convertedMembers = append(convertedMembers, resourcemanager.Member{ - Subject: &ownerSubject, - Role: &owner, + Subject: m.Subject, + Role: m.Role, }) } + var membersPayload *[]resourcemanager.Member + if len(convertedMembers) > 0 { + membersPayload = &convertedMembers + } modelLabels := model.Labels.Elements() labels, err := conversion.ToOptStringMap(modelLabels) if err != nil { - return nil, fmt.Errorf("converting to GO map: %w", err) + return nil, fmt.Errorf("converting to Go map: %w", err) } return &resourcemanager.CreateProjectPayload{ ContainerParentId: conversion.StringValueToPointer(model.ContainerParentId), Labels: labels, - Members: &members, + Members: membersPayload, Name: conversion.StringValueToPointer(model.Name), }, nil } @@ -478,3 +690,104 @@ func toUpdatePayload(model *Model) (*resourcemanager.PartialUpdateProjectPayload Labels: labels, }, nil } + +// updateMembers adds and removes members to match the model +func updateMembers(ctx context.Context, projectId string, modelMembers *[]authorization.Member, client *authorization.APIClient) error { + if modelMembers == nil || len(*modelMembers) == 0 { + return nil + } + + // Get current members + currentMembersResp, err := client.ListMembersExecute(ctx, projectResourceType, projectId) + if err != nil { + return fmt.Errorf("get members: %w", err) + } + + type memberState struct { + isInModel bool + isCreated bool + subject string + role string + } + + membersState := make(map[string]*memberState) // Key in the form of "subject,role" + for _, m := range *modelMembers { + mId := memberId(m) + membersState[mId] = &memberState{ + isInModel: true, + subject: *m.Subject, + role: *m.Role, + } + } + + for _, m := range *currentMembersResp.Members { + if utils.IsLegacyProjectRole(*m.Role) { + continue + } + + mId := memberId(m) + _, ok := membersState[mId] + if !ok { + membersState[mId] = &memberState{} + } + membersState[mId].isCreated = true + membersState[mId].subject = *m.Subject + membersState[mId].role = *m.Role + } + + // Add/remove members + membersToAdd := make([]authorization.Member, 0) + membersToRemove := make([]authorization.Member, 0) + for _, state := range membersState { + if state.isInModel && !state.isCreated { + m := authorization.Member{ + Subject: &state.subject, + Role: &state.role, + } + membersToAdd = append(membersToAdd, m) + + infoMsg := fmt.Sprintf("### Will add member to project: { role: %s, subject: %s }", state.role, state.subject) + tflog.Warn(ctx, infoMsg) + } + + if !state.isInModel && state.isCreated { + m := authorization.Member{ + Subject: &state.subject, + Role: &state.role, + } + membersToRemove = append(membersToRemove, m) + + infoMsg := fmt.Sprintf("### Will remove member from project: { role: %s, subject: %s }", state.role, state.subject) + tflog.Warn(ctx, infoMsg) + } + } + + if len(membersToAdd) > 0 { + payload := authorization.AddMembersPayload{ + Members: &membersToAdd, + ResourceType: sdkUtils.Ptr(projectResourceType), + } + _, err := client.AddMembers(ctx, projectId).AddMembersPayload(payload).Execute() + if err != nil { + return fmt.Errorf("add members: %w", err) + } + } + + if len(membersToRemove) > 0 { + payload := authorization.RemoveMembersPayload{ + Members: &membersToRemove, + ResourceType: sdkUtils.Ptr(projectResourceType), + } + _, err := client.RemoveMembers(ctx, projectId).RemoveMembersPayload(payload).Execute() + if err != nil { + return fmt.Errorf("remove members: %w", err) + } + } + + return nil +} + +// Internal representation of a member, which is uniquely identified by the subject and role +func memberId(member authorization.Member) string { + return fmt.Sprintf("%s,%s", *member.Subject, *member.Role) +} diff --git a/stackit/internal/services/resourcemanager/project/resource_test.go b/stackit/internal/services/resourcemanager/project/resource_test.go index 52c088fb..b0690452 100644 --- a/stackit/internal/services/resourcemanager/project/resource_test.go +++ b/stackit/internal/services/resourcemanager/project/resource_test.go @@ -2,22 +2,29 @@ package project import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" "github.com/google/go-cmp/cmp" "github.com/google/uuid" + "github.com/gorilla/mux" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/authorization" "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" ) -func TestMapFields(t *testing.T) { +func TestMapProjectFields(t *testing.T) { testUUID := uuid.New().String() tests := []struct { description string uuidContainerParentId bool - input *resourcemanager.GetProjectResponse + projectResp *resourcemanager.GetProjectResponse expected Model expectedLabels *map[string]string isValid bool @@ -35,6 +42,7 @@ func TestMapFields(t *testing.T) { ProjectId: types.StringValue("pid"), ContainerParentId: types.StringNull(), Name: types.StringNull(), + Members: types.ListNull(types.ObjectType{AttrTypes: memberTypes}), }, nil, true, @@ -61,6 +69,7 @@ func TestMapFields(t *testing.T) { ProjectId: types.StringValue("pid"), ContainerParentId: types.StringValue("parent_cid"), Name: types.StringValue("name"), + Members: types.ListNull(types.ObjectType{AttrTypes: memberTypes}), }, &map[string]string{ "label1": "ref1", @@ -90,6 +99,7 @@ func TestMapFields(t *testing.T) { ProjectId: types.StringValue("pid"), ContainerParentId: types.StringValue(testUUID), Name: types.StringValue("name"), + Members: types.ListNull(types.ObjectType{AttrTypes: memberTypes}), }, &map[string]string{ "label1": "ref1", @@ -129,12 +139,118 @@ func TestMapFields(t *testing.T) { if tt.uuidContainerParentId { containerParentId = types.StringValue(testUUID) } - state := &Model{ + model := &Model{ ContainerId: tt.expected.ContainerId, ContainerParentId: containerParentId, + Members: types.ListNull(types.ObjectType{AttrTypes: memberTypes}), } - err := mapFields(context.Background(), tt.input, state) + err := mapProjectFields(context.Background(), tt.projectResp, model, nil) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(model, &tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestMapMembersFields(t *testing.T) { + tests := []struct { + description string + membersResp *[]authorization.Member + expected Model + expectedLabels *map[string]string + isValid bool + }{ + { + "default_ok", + &[]authorization.Member{ + { + Subject: utils.Ptr("owner_email"), + Role: utils.Ptr("owner"), + }, + { + Subject: utils.Ptr("reader_email"), + Role: utils.Ptr("reader"), + }, + }, + Model{ + Id: types.StringNull(), + ProjectId: types.StringNull(), + ContainerId: types.StringNull(), + ContainerParentId: types.StringNull(), + Name: types.StringNull(), + Labels: types.MapNull(types.StringType), + Members: types.ListValueMust(types.ObjectType{AttrTypes: memberTypes}, []attr.Value{ + types.ObjectValueMust( + memberTypes, + map[string]attr.Value{ + "subject": types.StringValue("owner_email"), + "role": types.StringValue("owner"), + }, + ), + types.ObjectValueMust( + memberTypes, + map[string]attr.Value{ + "subject": types.StringValue("reader_email"), + "role": types.StringValue("reader"), + }, + ), + }), + }, + nil, + true, + }, + { + "empty members", + &[]authorization.Member{}, + Model{ + Id: types.StringNull(), + ProjectId: types.StringNull(), + ContainerId: types.StringNull(), + ContainerParentId: types.StringNull(), + Name: types.StringNull(), + Labels: types.MapNull(types.StringType), + Members: types.ListValueMust(types.ObjectType{AttrTypes: memberTypes}, []attr.Value{}), + }, + nil, + true, + }, + { + "nil members", + nil, + Model{ + Id: types.StringNull(), + ProjectId: types.StringNull(), + ContainerId: types.StringNull(), + ContainerParentId: types.StringNull(), + Name: types.StringNull(), + Members: types.ListNull(types.ObjectType{AttrTypes: memberTypes}), + Labels: types.MapNull(types.StringType), + }, + nil, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{ + Id: types.StringNull(), + ProjectId: types.StringNull(), + ContainerId: types.StringNull(), + ContainerParentId: types.StringNull(), + Name: types.StringNull(), + Labels: types.MapNull(types.StringType), + } + err := mapMembersFields(tt.membersResp, state) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -166,22 +282,25 @@ func TestToCreatePayload(t *testing.T) { &resourcemanager.CreateProjectPayload{ ContainerParentId: nil, Labels: nil, - Members: &[]resourcemanager.Member{ - { - Role: utils.Ptr(projectOwner), - Subject: utils.Ptr("service_account_email"), - }, - }, - Name: nil, + Members: nil, + Name: nil, }, true, }, { - "mapping_with_conversions_ok", + "mapping_with_conversions_single_member", &Model{ ContainerParentId: types.StringValue("pid"), Name: types.StringValue("name"), - OwnerEmail: types.StringValue("owner_email"), + Members: types.ListValueMust(types.ObjectType{AttrTypes: memberTypes}, []attr.Value{ + types.ObjectValueMust( + memberTypes, + map[string]attr.Value{ + "subject": types.StringValue("owner_email"), + "role": types.StringValue("owner"), + }, + ), + }), }, &map[string]string{ "label1": "1", @@ -195,12 +314,90 @@ func TestToCreatePayload(t *testing.T) { }, Members: &[]resourcemanager.Member{ { - Role: utils.Ptr(projectOwner), - Subject: utils.Ptr("service_account_email"), + Subject: utils.Ptr("owner_email"), + Role: utils.Ptr("owner"), + }, + }, + Name: utils.Ptr("name"), + }, + true, + }, + { + "mapping_with_conversions_ok_multiple_members", + &Model{ + ContainerParentId: types.StringValue("pid"), + Name: types.StringValue("name"), + Members: types.ListValueMust(types.ObjectType{AttrTypes: memberTypes}, []attr.Value{ + types.ObjectValueMust( + memberTypes, + map[string]attr.Value{ + "subject": types.StringValue("owner_email"), + "role": types.StringValue("owner"), + }, + ), + types.ObjectValueMust( + memberTypes, + map[string]attr.Value{ + "subject": types.StringValue("reader_email"), + "role": types.StringValue("reader"), + }, + ), + }), + }, + &map[string]string{ + "label1": "1", + "label2": "2", + }, + &resourcemanager.CreateProjectPayload{ + ContainerParentId: utils.Ptr("pid"), + Labels: &map[string]string{ + "label1": "1", + "label2": "2", + }, + Members: &[]resourcemanager.Member{ + { + Subject: utils.Ptr("owner_email"), + Role: utils.Ptr("owner"), }, { - Role: utils.Ptr(projectOwner), + Subject: utils.Ptr("reader_email"), + Role: utils.Ptr("reader"), + }, + }, + Name: utils.Ptr("name"), + }, + true, + }, + { + "new members field takes precedence over deprecated owner_email field", + &Model{ + ContainerParentId: types.StringValue("pid"), + Name: types.StringValue("name"), + OwnerEmail: types.StringValue("some_email_deprecated"), + Members: types.ListValueMust(types.ObjectType{AttrTypes: memberTypes}, []attr.Value{ + types.ObjectValueMust( + memberTypes, + map[string]attr.Value{ + "subject": types.StringValue("owner_email"), + "role": types.StringValue("owner"), + }, + ), + }), + }, + &map[string]string{ + "label1": "1", + "label2": "2", + }, + &resourcemanager.CreateProjectPayload{ + ContainerParentId: utils.Ptr("pid"), + Labels: &map[string]string{ + "label1": "1", + "label2": "2", + }, + Members: &[]resourcemanager.Member{ + { Subject: utils.Ptr("owner_email"), + Role: utils.Ptr("owner"), }, }, Name: utils.Ptr("name"), @@ -228,7 +425,7 @@ func TestToCreatePayload(t *testing.T) { tt.input.Labels = convertedLabels } } - output, err := toCreatePayload(tt.input, "service_account_email") + output, err := toCreatePayload(context.Background(), tt.input) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -322,3 +519,309 @@ func TestToUpdatePayload(t *testing.T) { }) } } + +var fixtureMembers = []authorization.Member{ + { + Subject: utils.Ptr("email_owner"), + Role: utils.Ptr("owner"), + }, + { + Subject: utils.Ptr("email_owner_2"), + Role: utils.Ptr("owner"), + }, + { + Subject: utils.Ptr("email_reader"), + Role: utils.Ptr("reader"), + }, +} + +func TestUpdateMembers(t *testing.T) { + // This is the response used when getting all members currently, across all tests + getAllMembersResp := authorization.MembersResponse{ + Members: &fixtureMembers, + } + getAllMembersRespBytes, err := json.Marshal(getAllMembersResp) + if err != nil { + t.Fatalf("Failed to marshal get all Members response: %v", err) + } + + // This is the response used whenever an API returns a failure response + failureRespBytes := []byte("{\"message\": \"Something bad happened\"") + + tests := []struct { + description string + modelMembers []authorization.Member + getAllMembersFails bool + addMembersFails bool + removeMembersFails bool + isValid bool + expectedMembersStates map[string]bool // Keys are member; value is true if member should exist at the end, false otherwise + }{ + { + description: "no changes", + modelMembers: fixtureMembers, + expectedMembersStates: map[string]bool{ + memberId(fixtureMembers[0]): true, + memberId(fixtureMembers[1]): true, + memberId(fixtureMembers[2]): true, + }, + isValid: true, + }, + { + description: "add one member", + modelMembers: append( + fixtureMembers, + authorization.Member{Subject: utils.Ptr("email_reader_2"), Role: utils.Ptr("reader")}, + ), + expectedMembersStates: map[string]bool{ + memberId(fixtureMembers[0]): true, + memberId(fixtureMembers[1]): true, + memberId(fixtureMembers[2]): true, + "email_reader_2,reader": true, + }, + isValid: true, + }, + { + description: "add multiple members", + modelMembers: append( + fixtureMembers, + authorization.Member{Subject: utils.Ptr("email_reader_2"), Role: utils.Ptr("reader")}, + authorization.Member{Subject: utils.Ptr("email_reader_3"), Role: utils.Ptr("reader")}, + ), + expectedMembersStates: map[string]bool{ + memberId(fixtureMembers[0]): true, + memberId(fixtureMembers[1]): true, + memberId(fixtureMembers[2]): true, + "email_reader_2,reader": true, + "email_reader_3,reader": true, + }, + isValid: true, + }, + { + description: "removing member", + modelMembers: fixtureMembers[:2], + expectedMembersStates: map[string]bool{ + memberId(fixtureMembers[0]): true, + memberId(fixtureMembers[1]): true, + memberId(fixtureMembers[2]): false, + }, + isValid: true, + }, + { + description: "removing multiple members", + modelMembers: fixtureMembers[:1], + expectedMembersStates: map[string]bool{ + memberId(fixtureMembers[0]): true, + memberId(fixtureMembers[1]): false, + memberId(fixtureMembers[2]): false, + }, + isValid: true, + }, + { + description: "multiple changes (add and remove)", + modelMembers: append( + fixtureMembers[:2], + authorization.Member{Subject: utils.Ptr("email_reader_2"), Role: utils.Ptr("reader")}, + authorization.Member{Subject: utils.Ptr("email_reader_3"), Role: utils.Ptr("reader")}, + ), + expectedMembersStates: map[string]bool{ + memberId(fixtureMembers[0]): true, + memberId(fixtureMembers[1]): true, + memberId(fixtureMembers[2]): false, + "email_reader_2,reader": true, + "email_reader_3,reader": true, + }, + isValid: true, + }, + { + description: "multiple changes 2 (add and remove)", + modelMembers: []authorization.Member{ + {Subject: utils.Ptr("email_reader_2"), Role: utils.Ptr("reader")}, + {Subject: utils.Ptr("email_reader_3"), Role: utils.Ptr("reader")}, + }, + expectedMembersStates: map[string]bool{ + memberId(fixtureMembers[0]): false, + memberId(fixtureMembers[1]): false, + memberId(fixtureMembers[2]): false, + "email_reader_2,reader": true, + "email_reader_3,reader": true, + }, + isValid: true, + }, + { + description: "get fails", + modelMembers: fixtureMembers, + getAllMembersFails: true, + isValid: false, + }, + { + description: "add fails", + modelMembers: append( + fixtureMembers, + authorization.Member{Subject: utils.Ptr("email_reader_2"), Role: utils.Ptr("reader")}, + ), + addMembersFails: true, + isValid: false, + }, + { + description: "remove fails", + modelMembers: fixtureMembers[:1], + removeMembersFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + // Will be compared to tt.expectedMembersStates at the end + membersStates := map[string]bool{ + memberId(fixtureMembers[0]): true, + memberId(fixtureMembers[1]): true, + memberId(fixtureMembers[2]): true, + } + + // Handler for getting all Members + getAllMembersHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if tt.getAllMembersFails { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write(failureRespBytes) + if err != nil { + t.Errorf("Get all Members handler: failed to write bad response: %v", err) + } + return + } + + _, err := w.Write(getAllMembersRespBytes) + if err != nil { + t.Errorf("Get all Members handler: failed to write response: %v", err) + } + }) + + // Handler for adding members + addMembersHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + var payload authorization.AddMembersPayload + err := decoder.Decode(&payload) + if err != nil { + t.Errorf("Add members handler: failed to parse payload") + return + } + if payload.Members == nil { + t.Errorf("Add members handler: nil members") + return + } + members := *payload.Members + for _, m := range members { + if memberExists, memberWasAdded := membersStates[memberId(m)]; memberWasAdded && memberExists { + t.Errorf("Add members handler: attempted to add member '%v' that already exists", memberId(m)) + return + } + } + + w.Header().Set("Content-Type", "application/json") + if tt.addMembersFails { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write(failureRespBytes) + if err != nil { + t.Errorf("Add members handler: failed to write bad response: %v", err) + } + return + } + + for _, m := range members { + membersStates[memberId(m)] = true + } + }) + + // Handler for removing members + removeMembersHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + var payload authorization.RemoveMembersPayload + err := decoder.Decode(&payload) + if err != nil { + t.Errorf("Remove members handler: failed to parse payload") + return + } + if payload.Members == nil { + t.Errorf("Remove members handler: nil members") + return + } + members := *payload.Members + for _, m := range members { + memberExists, memberWasCreated := membersStates[memberId(m)] + if !memberWasCreated { + t.Errorf("Remove members handler: attempted to remove member '%v' that wasn't created", memberId(m)) + return + } + if memberWasCreated && !memberExists { + t.Errorf("Remove members handler: attempted to remove member '%v' that was already removed", memberId(m)) + return + } + } + + w.Header().Set("Content-Type", "application/json") + if tt.removeMembersFails { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write(failureRespBytes) + if err != nil { + t.Errorf("Remove members handler: failed to write bad response: %v", err) + } + return + } + + for _, m := range members { + membersStates[memberId(m)] = false + } + }) + + // Setup server and client + router := mux.NewRouter() + router.HandleFunc("/v2/{resourceType}/{resourceId}/members", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + getAllMembersHandler(w, r) + } else { + t.Fatalf("Unexpected method: %v", r.Method) + } + }) + router.HandleFunc("/v2/{resourceId}/members", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPatch { + addMembersHandler(w, r) + } else { + t.Fatalf("Unexpected method: %v", r.Method) + } + }) + router.HandleFunc("/v2/{resourceId}/members/remove", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + removeMembersHandler(w, r) + } else { + t.Fatalf("Unexpected method: %v", r.Method) + } + }) + mockedServer := httptest.NewServer(router) + defer mockedServer.Close() + client, err := authorization.NewAPIClient( + config.WithEndpoint(mockedServer.URL), + config.WithoutAuthentication(), + ) + if err != nil { + t.Fatalf("Failed to initialize client: %v", err) + } + + // Run test + err = updateMembers(context.Background(), "pid", &tt.modelMembers, client) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(membersStates, tt.expectedMembersStates) + if diff != "" { + t.Fatalf("Member states do not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go b/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go index 735d032c..4877a4ae 100644 --- a/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go +++ b/stackit/internal/services/resourcemanager/resourcemanager_acc_test.go @@ -3,6 +3,7 @@ package resourcemanager_test import ( "context" "fmt" + "strings" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" @@ -10,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/authorization" "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager/wait" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" @@ -24,7 +26,19 @@ var projectResource = map[string]string{ "new_label": "a-label", } -func resourceConfig(name string, label *string) string { +func membersConfig(members []authorization.Member) string { + membersConfig := make([]string, 0, len(members)) + for _, m := range members { + memberConfig := fmt.Sprintf(`{ + subject = "%s" + role = "%s" + }`, *m.Subject, *m.Role) + membersConfig = append(membersConfig, memberConfig) + } + return strings.Join(membersConfig, ",\n") +} + +func resourceConfig(name string, label *string, members string) string { labelConfig := "" if label != nil { labelConfig = fmt.Sprintf("new_label = %q", *label) @@ -39,13 +53,17 @@ func resourceConfig(name string, label *string) string { "billing_reference" = "%[4]s" %[5]s } - owner_email = "%[6]s" + members = [ + %[7]s + ] } resource "stackit_resourcemanager_project" "parent_by_uuid" { - parent_container_id = "%[7]s" + parent_container_id = "%[6]s" name = "%[3]s-uuid" - owner_email = "%[6]s" + members = [ + %[7]s + ] } `, testutil.ResourceManagerProviderConfig(), @@ -53,19 +71,37 @@ func resourceConfig(name string, label *string) string { name, projectResource["billing_reference"], labelConfig, - testutil.TestProjectServiceAccountEmail, projectResource["parent_uuid"], + members, ) } func TestAccResourceManagerResource(t *testing.T) { + initialMembersConfig := membersConfig([]authorization.Member{ + { + Subject: &testutil.TestProjectUserEmail, + Role: utils.Ptr("owner"), + }, + }) + + updatedMembersConfig := membersConfig([]authorization.Member{ + { + Subject: &testutil.TestProjectUserEmail, + Role: utils.Ptr("owner"), + }, + { + Subject: &testutil.TestProjectUserEmail, + Role: utils.Ptr("reader"), + }, + }) + resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, CheckDestroy: testAccCheckResourceManagerDestroy, Steps: []resource.TestStep{ // Creation { - Config: resourceConfig(projectResource["name"], nil), + Config: resourceConfig(projectResource["name"], nil, initialMembersConfig), Check: resource.ComposeAggregateTestCheckFunc( // Parent container id project data resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.parent_by_container", "container_id"), @@ -100,7 +136,7 @@ func TestAccResourceManagerResource(t *testing.T) { project_id = stackit_resourcemanager_project.parent_by_container.project_id } `, - resourceConfig(projectResource["name"], nil), + resourceConfig(projectResource["name"], nil, initialMembersConfig), ), Check: resource.ComposeAggregateTestCheckFunc( // Container project data @@ -150,11 +186,11 @@ func TestAccResourceManagerResource(t *testing.T) { ImportStateVerify: true, // The owner_email attributes don't exist in the // API, therefore there is no value for it during import. - ImportStateVerifyIgnore: []string{"owner_email"}, + ImportStateVerifyIgnore: []string{"owner_email", "members"}, }, // Update { - Config: resourceConfig(fmt.Sprintf("%s-new", projectResource["name"]), utils.Ptr("a-label")), + Config: resourceConfig(fmt.Sprintf("%s-new", projectResource["name"]), utils.Ptr("a-label"), updatedMembersConfig), Check: resource.ComposeAggregateTestCheckFunc( // Project data resource.TestCheckResourceAttrSet("stackit_resourcemanager_project.parent_by_container", "container_id"), diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index d539d6e2..5107b836 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -38,6 +38,8 @@ var ( TestProjectParentUUID = os.Getenv("TF_ACC_TEST_PROJECT_PARENT_UUID") // TestProjectServiceAccountEmail is the e-mail of a service account with admin permissions on the organization under which projects are created as part of the resource-manager acceptance tests TestProjectServiceAccountEmail = os.Getenv("TF_ACC_TEST_PROJECT_SERVICE_ACCOUNT_EMAIL") + // TestProjectUserEmail is the e-mail of a user for the project created as part of the resource-manager acceptance tests + TestProjectUserEmail = os.Getenv("TF_ACC_TEST_PROJECT_USER_EMAIL") ArgusCustomEndpoint = os.Getenv("TF_ACC_ARGUS_CUSTOM_ENDPOINT") DnsCustomEndpoint = os.Getenv("TF_ACC_DNS_CUSTOM_ENDPOINT") @@ -45,6 +47,7 @@ var ( LoadBalancerCustomEndpoint = os.Getenv("TF_ACC_LOADBALANCER_CUSTOM_ENDPOINT") LogMeCustomEndpoint = os.Getenv("TF_ACC_LOGME_CUSTOM_ENDPOINT") MariaDBCustomEndpoint = os.Getenv("TF_ACC_MARIADB_CUSTOM_ENDPOINT") + AuthorizationCustomEndpoint = os.Getenv("TF_ACC_authorization_custom_endpoint") MongoDBFlexCustomEndpoint = os.Getenv("TF_ACC_MONGODBFLEX_CUSTOM_ENDPOINT") OpenSearchCustomEndpoint = os.Getenv("TF_ACC_OPENSEARCH_CUSTOM_ENDPOINT") ObjectStorageCustomEndpoint = os.Getenv("TF_ACC_OBJECTSTORAGE_CUSTOM_ENDPOINT") @@ -261,7 +264,7 @@ func RedisProviderConfig() string { func ResourceManagerProviderConfig() string { token := getTestProjectServiceAccountToken("") - if ResourceManagerCustomEndpoint == "" { + if ResourceManagerCustomEndpoint == "" || AuthorizationCustomEndpoint == "" { return fmt.Sprintf(` provider "stackit" { service_account_email = "%s" @@ -274,10 +277,12 @@ func ResourceManagerProviderConfig() string { return fmt.Sprintf(` provider "stackit" { resourcemanager_custom_endpoint = "%s" + authorization_custom_endpoint = "%s" service_account_email = "%s" service_account_token = "%s" }`, ResourceManagerCustomEndpoint, + AuthorizationCustomEndpoint, TestProjectServiceAccountEmail, token, ) diff --git a/stackit/internal/utils/utils.go b/stackit/internal/utils/utils.go index 60ee8f25..f4764659 100644 --- a/stackit/internal/utils/utils.go +++ b/stackit/internal/utils/utils.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -14,6 +15,10 @@ const ( SKEServiceId = "cloud.stackit.ske" ) +var ( + LegacyProjectRoles = []string{"project.admin", "project.auditor", "project.member", "project.owner"} +) + // ReconcileStringSlices reconciles two string lists by removing elements from the // first list that are not in the second list and appending elements from the // second list that are not in the first list. @@ -85,13 +90,17 @@ func SupportedValuesDocumentation(values []string) string { if len(values) == 0 { return "" } - return "Supported values are: " + strings.Join(quoteValues(values), ", ") + "." + return "Supported values are: " + strings.Join(QuoteValues(values), ", ") + "." } -func quoteValues(values []string) []string { +func QuoteValues(values []string) []string { quotedValues := make([]string, len(values)) for i, value := range values { quotedValues[i] = fmt.Sprintf("`%s`", value) } return quotedValues } + +func IsLegacyProjectRole(role string) bool { + return utils.Contains(LegacyProjectRoles, role) +} diff --git a/stackit/internal/utils/utils_test.go b/stackit/internal/utils/utils_test.go index 77a1e32f..3ab01e58 100644 --- a/stackit/internal/utils/utils_test.go +++ b/stackit/internal/utils/utils_test.go @@ -225,3 +225,46 @@ func TestSupportedValuesDocumentation(t *testing.T) { }) } } + +func TestIsLegacyProjectRole(t *testing.T) { + tests := []struct { + description string + role string + expected bool + }{ + { + "non legacy role", + "owner", + false, + }, + { + "leagcy role", + "project.owner", + true, + }, + { + "leagcy role 2", + "project.admin", + true, + }, + { + "leagcy role 3", + "project.member", + true, + }, + { + "leagcy role 4", + "project.auditor", + true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := IsLegacyProjectRole(tt.role) + if output != tt.expected { + t.Fatalf("Data does not match: %v", output) + } + }) + } +} diff --git a/stackit/internal/validate/validate.go b/stackit/internal/validate/validate.go index c5afe02f..8b26bd16 100644 --- a/stackit/internal/validate/validate.go +++ b/stackit/internal/validate/validate.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/teambition/rrule-go" ) @@ -137,6 +138,23 @@ func NoSeparator() *Validator { } } +func NonLegacyProjectRole() *Validator { + description := "legacy roles are not supported" + + return &Validator{ + description: description, + validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if utils.IsLegacyProjectRole(req.ConfigValue.ValueString()) { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + description, + req.ConfigValue.ValueString(), + )) + } + }, + } +} + func MinorVersionNumber() *Validator { description := "value must be a minor version number, without a leading 'v': '[MAJOR].[MINOR]'" diff --git a/stackit/internal/validate/validate_test.go b/stackit/internal/validate/validate_test.go index 5a374842..584adf2e 100644 --- a/stackit/internal/validate/validate_test.go +++ b/stackit/internal/validate/validate_test.go @@ -299,6 +299,60 @@ func TestNoSeparator(t *testing.T) { } } +func TestNonLegacyProjectRole(t *testing.T) { + tests := []struct { + description string + input string + isValid bool + }{ + { + "ok", + "owner", + true, + }, + { + "ok-2", + "reader", + true, + }, + { + "leagcy-role", + "project.owner", + false, + }, + { + "leagcy-role-2", + "project.admin", + false, + }, + { + "leagcy-role-3", + "project.member", + false, + }, + { + "leagcy-role-4", + "project.auditor", + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + r := validator.StringResponse{} + NonLegacyProjectRole().ValidateString(context.Background(), validator.StringRequest{ + ConfigValue: types.StringValue(tt.input), + }, &r) + + if !tt.isValid && !r.Diagnostics.HasError() { + t.Fatalf("Should have failed") + } + if tt.isValid && r.Diagnostics.HasError() { + t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors()) + } + }) + } +} + func TestMinorVersionNumber(t *testing.T) { tests := []struct { description string diff --git a/stackit/provider.go b/stackit/provider.go index c83c412a..2f47c2eb 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -97,6 +97,7 @@ type providerModel struct { LogMeCustomEndpoint types.String `tfsdk:"logme_custom_endpoint"` RabbitMQCustomEndpoint types.String `tfsdk:"rabbitmq_custom_endpoint"` MariaDBCustomEndpoint types.String `tfsdk:"mariadb_custom_endpoint"` + AuthorizationCustomEndpoint types.String `tfsdk:"authorization_custom_endpoint"` ObjectStorageCustomEndpoint types.String `tfsdk:"objectstorage_custom_endpoint"` OpenSearchCustomEndpoint types.String `tfsdk:"opensearch_custom_endpoint"` RedisCustomEndpoint types.String `tfsdk:"redis_custom_endpoint"` @@ -129,6 +130,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "logme_custom_endpoint": "Custom endpoint for the LogMe service", "rabbitmq_custom_endpoint": "Custom endpoint for the RabbitMQ service", "mariadb_custom_endpoint": "Custom endpoint for the MariaDB service", + "authorization_custom_endpoint": "Custom endpoint for the Membership service", "objectstorage_custom_endpoint": "Custom endpoint for the Object Storage service", "opensearch_custom_endpoint": "Custom endpoint for the OpenSearch service", "postgresql_custom_endpoint": "Custom endpoint for the PostgreSQL service", @@ -198,6 +200,14 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["postgresflex_custom_endpoint"], }, + "mariadb_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["mariadb_custom_endpoint"], + }, + "authorization_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["authorization_custom_endpoint"], + }, "mongodbflex_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["mongodbflex_custom_endpoint"], @@ -214,10 +224,6 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["rabbitmq_custom_endpoint"], }, - "mariadb_custom_endpoint": schema.StringAttribute{ - Optional: true, - Description: descriptions["mariadb_custom_endpoint"], - }, "objectstorage_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["objectstorage_custom_endpoint"], @@ -335,6 +341,9 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, if !(providerConfig.MariaDBCustomEndpoint.IsUnknown() || providerConfig.MariaDBCustomEndpoint.IsNull()) { providerData.MariaDBCustomEndpoint = providerConfig.MariaDBCustomEndpoint.ValueString() } + if !(providerConfig.AuthorizationCustomEndpoint.IsUnknown() || providerConfig.AuthorizationCustomEndpoint.IsNull()) { + providerData.AuthorizationCustomEndpoint = providerConfig.AuthorizationCustomEndpoint.ValueString() + } if !(providerConfig.ObjectStorageCustomEndpoint.IsUnknown() || providerConfig.ObjectStorageCustomEndpoint.IsNull()) { providerData.ObjectStorageCustomEndpoint = providerConfig.ObjectStorageCustomEndpoint.ValueString() }