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()
}