diff --git a/docs/data-sources/service_account_federated_identity_provider.md b/docs/data-sources/service_account_federated_identity_provider.md new file mode 100644 index 000000000..841df9866 --- /dev/null +++ b/docs/data-sources/service_account_federated_identity_provider.md @@ -0,0 +1,51 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_service_account_federated_identity_provider Data Source - stackit" +subcategory: "" +description: |- + Service account federated identity provider schema. +--- + +# stackit_service_account_federated_identity_provider (Data Source) + +Service account federated identity provider schema. + +## Example Usage + +```terraform +data "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email = "sa01-8565oq1@sa.stackit.cloud" +} + +data "stackit_service_account_federated_identity_provider" "provider" { + project_id = data.stackit_service_account.sa.project_id + service_account_email = data.stackit_service_account.sa.email + federation_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `federation_id` (String) The unique identifier for the federated identity provider associated with the service account. +- `project_id` (String) The STACKIT project ID associated with the service account. +- `service_account_email` (String) The email address associated with the service account, used for account identification and communication. + +### Read-Only + +- `assertions` (Attributes List) (see [below for nested schema](#nestedatt--assertions)) +- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`service_account_email`,`federation_id`". +- `issuer` (String) +- `name` (String) + + +### Nested Schema for `assertions` + +Read-Only: + +- `item` (String) +- `operator` (String) +- `value` (String) diff --git a/docs/resources/service_account_federated_identity_provider.md b/docs/resources/service_account_federated_identity_provider.md new file mode 100644 index 000000000..f24a97ac7 --- /dev/null +++ b/docs/resources/service_account_federated_identity_provider.md @@ -0,0 +1,129 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_service_account_federated_identity_provider Resource - stackit" +subcategory: "" +description: |- + Service account federated identity provider schema. + Example Usage + Create a federated identity provider + + resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my-service-account" + } + + resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = "my-provider" + issuer = "https://auth.example.com" + + assertions = [ + { + item = "aud" # Including the audience check is mandatory for security reasons, the value is free to choose + operator = "equals" + value = "sts.accounts.stackit.cloud" + }, + { + item = "email" + operator = "equals" + value = "terraform@example.com" + } + ] + } +--- + +# stackit_service_account_federated_identity_provider (Resource) + +Service account federated identity provider schema. +## Example Usage + + +### Create a federated identity provider +```terraform +resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my-service-account" +} + +resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = "my-provider" + issuer = "https://auth.example.com" + + assertions = [ + { + item = "aud" # Including the audience check is mandatory for security reasons, the value is free to choose + operator = "equals" + value = "sts.accounts.stackit.cloud" + }, + { + item = "email" + operator = "equals" + value = "terraform@example.com" + } + ] +} + +``` + +## Example Usage + +```terraform +resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my-service-account" +} + +resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = "gh-actions" + issuer = "https://token.actions.githubusercontent.com" + + assertions = [ + { + item = "aud" + operator = "equals" + value = "sts.accounts.stackit.cloud" + }, + { + item = "sub" + operator = "equals" + value = "repo:stackitcloud/terraform-provider-stackit:ref:refs/heads/main" + } + ] +} + +# Only use the import statement, if you want to import an existing federated identity provider +import { + to = stackit_service_account_federated_identity_provider.import-example + id = "${var.project_id},${var.service_account_email},${var.federation_id}" +} +``` + + +## Schema + +### Required + +- `assertions` (Attributes List) The assertions for the federated identity provider. (see [below for nested schema](#nestedatt--assertions)) +- `issuer` (String) The issuer URL. +- `name` (String) The name of the federated identity provider. +- `project_id` (String) The STACKIT project ID associated with the service account. +- `service_account_email` (String) The email address associated with the service account, used for account identification and communication. + +### Read-Only + +- `federation_id` (String) The unique identifier for the federated identity provider associated with the service account. +- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`service_account_email`,`federation_id`". + + +### Nested Schema for `assertions` + +Required: + +- `item` (String) The assertion claim. At least one assertion with the claim "aud" is required for security reasons. +- `operator` (String) The assertion operator. Currently, the only supported operator is "equals". +- `value` (String) The assertion value. diff --git a/examples/data-sources/stackit_service_account_federated_identity_provider/data-source.tf b/examples/data-sources/stackit_service_account_federated_identity_provider/data-source.tf new file mode 100644 index 000000000..83a394e5e --- /dev/null +++ b/examples/data-sources/stackit_service_account_federated_identity_provider/data-source.tf @@ -0,0 +1,11 @@ +data "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email = "sa01-8565oq1@sa.stackit.cloud" +} + +data "stackit_service_account_federated_identity_provider" "provider" { + project_id = data.stackit_service_account.sa.project_id + service_account_email = data.stackit_service_account.sa.email + federation_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + diff --git a/examples/resources/stackit_service_account_federated_identity_provider/resource.tf b/examples/resources/stackit_service_account_federated_identity_provider/resource.tf new file mode 100644 index 000000000..4a6d44a84 --- /dev/null +++ b/examples/resources/stackit_service_account_federated_identity_provider/resource.tf @@ -0,0 +1,30 @@ +resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my-service-account" +} + +resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = "gh-actions" + issuer = "https://token.actions.githubusercontent.com" + + assertions = [ + { + item = "aud" + operator = "equals" + value = "sts.accounts.stackit.cloud" + }, + { + item = "sub" + operator = "equals" + value = "repo:stackitcloud/terraform-provider-stackit:ref:refs/heads/main" + } + ] +} + +# Only use the import statement, if you want to import an existing federated identity provider +import { + to = stackit_service_account_federated_identity_provider.import-example + id = "${var.project_id},${var.service_account_email},${var.federation_id}" +} diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/const.go b/stackit/internal/services/serviceaccount/federated_identity_provider/const.go new file mode 100644 index 000000000..e1919e079 --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/const.go @@ -0,0 +1,32 @@ +package federated_identity_provider + +const markdownDescription = ` +## Example Usage` + "\n" + ` + +### Create a federated identity provider` + "\n" + + "```terraform" + ` +resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my-service-account" +} + +resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = "my-provider" + issuer = "https://auth.example.com" + + assertions = [ + { + item = "aud" # Including the audience check is mandatory for security reasons, the value is free to choose + operator = "equals" + value = "sts.accounts.stackit.cloud" + }, + { + item = "email" + operator = "equals" + value = "terraform@example.com" + } + ] +} +` + "\n```" diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/datasource.go b/stackit/internal/services/serviceaccount/federated_identity_provider/datasource.go new file mode 100644 index 000000000..494777bd6 --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/datasource.go @@ -0,0 +1,156 @@ +package federated_identity_provider + +import ( + "context" + "errors" + "fmt" + "net/http" + + serviceaccountUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + serviceaccount "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount/v2api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" +) + +var ( + _ datasource.DataSource = &serviceAccountFederatedIdentityProviderDatasource{} +) + +func NewServiceAccountFederatedIdentityProviderDataSource() datasource.DataSource { + return &serviceAccountFederatedIdentityProviderDatasource{} +} + +type serviceAccountFederatedIdentityProviderDatasource struct { + client *serviceaccount.APIClient +} + +func (r *serviceAccountFederatedIdentityProviderDatasource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_account_federated_identity_provider" +} + +func (r *serviceAccountFederatedIdentityProviderDatasource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + descriptions := map[string]string{ + "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`service_account_email`,`federation_id`\".", + "main": "Service account federated identity provider schema.", + "project_id": "The STACKIT project ID associated with the service account.", + "federation_id": "The unique identifier for the federated identity provider associated with the service account.", + "service_account_email": "The email address associated with the service account, used for account identification and communication.", + } + resp.Schema = schema.Schema{ + MarkdownDescription: descriptions["main"], + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: descriptions["id"], + Validators: []validator.String{ + validate.UUID(), + }, + }, + "project_id": schema.StringAttribute{ + Required: true, + Description: descriptions["project_id"], + Validators: []validator.String{ + validate.UUID(), + }, + }, + "service_account_email": schema.StringAttribute{ + Required: true, + Description: descriptions["service_account_email"], + }, + "federation_id": schema.StringAttribute{ + Description: descriptions["federation_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + }, + }, + "name": schema.StringAttribute{ + Computed: true, + Description: descriptions["name"], + }, + "issuer": schema.StringAttribute{ + Computed: true, + Description: descriptions["issuer"], + }, + "assertions": schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "item": schema.StringAttribute{ + Computed: true, + Description: descriptions["assertions.item"], + }, + "operator": schema.StringAttribute{ + Computed: true, + Description: descriptions["assertions.operator"], + }, + "value": schema.StringAttribute{ + Computed: true, + Description: descriptions["assertions.value"], + }, + }, + }, + Computed: true, + Description: descriptions["assertions"], + }, + }, + } +} + +func (r *serviceAccountFederatedIdentityProviderDatasource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := serviceaccountUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Service Account client configured") +} + +func (r *serviceAccountFederatedIdentityProviderDatasource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { //nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + serviceAccountEmail := model.ServiceAccountEmail.ValueString() + federationId := model.FederationId.ValueString() + + apiResp, err := r.client.DefaultAPI.GetFederatedIdentityProvider(ctx, projectId, serviceAccountEmail, federationId). + Execute() + + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + // due to security purposes, attempting to get access federation for a non-existent Service Account will return 403. + if ok && oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusForbidden || oapiErr.StatusCode == http.StatusBadRequest { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading federated identity provider", fmt.Sprintf("Calling API: %v", err)) + return + } + + if err := mapFields(ctx, apiResp, &model, projectId, serviceAccountEmail); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading federated identity provider", fmt.Sprintf("failed to map response to model: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go b/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go new file mode 100644 index 000000000..aa352de37 --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go @@ -0,0 +1,472 @@ +package federated_identity_provider + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + serviceaccountUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + serviceaccount "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount/v2api" + + "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" +) + +var ( + _ resource.Resource = &serviceAccountFederatedIdentityProviderResource{} + _ resource.ResourceWithConfigure = &serviceAccountFederatedIdentityProviderResource{} + _ resource.ResourceWithImportState = &serviceAccountFederatedIdentityProviderResource{} +) + +// Model describes the resource data model. +type Model struct { + Id types.String `tfsdk:"id"` + ProjectId types.String `tfsdk:"project_id"` + ServiceAccountEmail types.String `tfsdk:"service_account_email"` + FederationId types.String `tfsdk:"federation_id"` + Name types.String `tfsdk:"name"` + Issuer types.String `tfsdk:"issuer"` + Assertions types.List `tfsdk:"assertions"` +} + +// AssertionModel describes an assertion in the assertions list. +type AssertionModel struct { + Item types.String `tfsdk:"item"` + Operator types.String `tfsdk:"operator"` + Value types.String `tfsdk:"value"` +} + +func NewServiceAccountFederatedIdentityProviderResource() resource.Resource { + return &serviceAccountFederatedIdentityProviderResource{} +} + +type serviceAccountFederatedIdentityProviderResource struct { + client *serviceaccount.APIClient +} + +func (r *serviceAccountFederatedIdentityProviderResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_account_federated_identity_provider" +} + +func (r *serviceAccountFederatedIdentityProviderResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ + "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`service_account_email`,`federation_id`\".", + "main": "Service account federated identity provider schema.", + "project_id": "The STACKIT project ID associated with the service account.", + "federation_id": "The unique identifier for the federated identity provider associated with the service account.", + "service_account_email": "The email address associated with the service account, used for account identification and communication.", + "name": "The name of the federated identity provider.", + "issuer": "The issuer URL.", + "assertions": "The assertions for the federated identity provider.", + "assertions.item": "The assertion claim. At least one assertion with the claim \"aud\" is required for security reasons.", + "assertions.operator": "The assertion operator. Currently, the only supported operator is \"equals\".", + "assertions.value": "The assertion value.", + } + resp.Schema = schema.Schema{ + MarkdownDescription: fmt.Sprintf("%s%s", descriptions["main"], markdownDescription), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: descriptions["id"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + }, + }, + "project_id": schema.StringAttribute{ + Required: true, + Description: descriptions["project_id"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + }, + }, + "service_account_email": schema.StringAttribute{ + Required: true, + Description: descriptions["service_account_email"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "federation_id": schema.StringAttribute{ + Description: descriptions["federation_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Required: true, + Description: descriptions["name"], + }, + "issuer": schema.StringAttribute{ + Required: true, + Description: descriptions["issuer"], + }, + "assertions": schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "item": schema.StringAttribute{ + Required: true, + Description: descriptions["assertions.item"], + }, + "operator": schema.StringAttribute{ + Required: true, + Description: descriptions["assertions.operator"], + Validators: []validator.String{ + stringvalidator.OneOf("equals"), + }, + }, + "value": schema.StringAttribute{ + Required: true, + Description: descriptions["assertions.value"], + }, + }, + }, + Required: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + listvalidator.SizeAtMost(50), // This is the current page limit for assertions. + requireAssertions(), + }, + Description: descriptions["assertions"], + }, + }, + } +} + +func (r *serviceAccountFederatedIdentityProviderResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := serviceaccountUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Service Account client configured") +} + +func (r *serviceAccountFederatedIdentityProviderResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { //nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + serviceAccountEmail := model.ServiceAccountEmail.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "service_account_email", serviceAccountEmail) + + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating federated identity provider", fmt.Sprintf("failed to convert model to payload: %v", err)) + return + } + + apiResp, err := r.client.DefaultAPI.CreateFederatedIdentityProvider(ctx, projectId, serviceAccountEmail). + CreateFederatedIdentityProviderPayload(*payload). + Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating federated identity provider", fmt.Sprintf("Calling API: %v", err)) + return + } + + if err := mapFields(ctx, apiResp, &model, projectId, serviceAccountEmail); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating federated identity provider", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +func (r *serviceAccountFederatedIdentityProviderResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { //nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + serviceAccountEmail := model.ServiceAccountEmail.ValueString() + federationId := model.FederationId.ValueString() + + apiResp, err := r.client.DefaultAPI.GetFederatedIdentityProvider(ctx, projectId, serviceAccountEmail, federationId). + Execute() + + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + // due to security purposes, attempting to get access federation for a non-existent Service Account will return 403. + if ok && oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusForbidden || oapiErr.StatusCode == http.StatusBadRequest { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading federated identity provider", fmt.Sprintf("Calling API: %v", err)) + return + } + + if err := mapFields(ctx, apiResp, &model, projectId, serviceAccountEmail); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading federated identity provider", fmt.Sprintf("failed to map response to model: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +func (r *serviceAccountFederatedIdentityProviderResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { //nolint:gocritic // function signature required by Terraform + // Read the plan to get the desired configuration + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // federation_id is a computed field only available in the current state, not the plan + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + model.FederationId = stateModel.FederationId + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + serviceAccountEmail := model.ServiceAccountEmail.ValueString() + federationId := model.FederationId.ValueString() + + payload, err := toUpdatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating federated identity provider", fmt.Sprintf("failed to convert model to payload: %v", err)) + return + } + + apiResp, err := r.client.DefaultAPI.PartialUpdateServiceAccountFederatedIdentityProvider(ctx, projectId, serviceAccountEmail, federationId). + PartialUpdateServiceAccountFederatedIdentityProviderPayload(*payload). + Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating federated identity provider", fmt.Sprintf("Calling API: %v", err)) + return + } + + if err := mapFields(ctx, apiResp, &model, projectId, serviceAccountEmail); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating federated identity provider", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +func (r *serviceAccountFederatedIdentityProviderResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { //nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + serviceAccountEmail := model.ServiceAccountEmail.ValueString() + federationId := model.FederationId.ValueString() + + err := r.client.DefaultAPI.DeleteServiceFederatedIdentityProvider(ctx, projectId, serviceAccountEmail, federationId). + Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting federated identity provider", fmt.Sprintf("Calling API: %v", err)) + return + } +} + +func (r *serviceAccountFederatedIdentityProviderResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Split the import identifier to extract project ID and email. + idParts := strings.Split(req.ID, core.Separator) + + // Ensure the import identifier format is correct. + if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing federated identity provider", + fmt.Sprintf("Expected import identifier with format: [project_id],[email],[federation_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + email := idParts[1] + federationId := idParts[2] + + // Attempt to parse the name from the email if valid. + _, err := serviceaccountUtils.ParseNameFromEmail(email) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing federated identity provider", + fmt.Sprintf("Invalid service account email: %v", err), + ) + return + } + + // Set the project ID, email and federation ID attributes in the state. + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("service_account_email"), email)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("federation_id"), federationId)...) + + tflog.Info(ctx, "Federated identity provider state imported") +} + +func mapFields(ctx context.Context, apiResp *serviceaccount.FederatedIdentityProvider, model *Model, projectId, serviceAccountEmail string) error { + if apiResp == nil { + return fmt.Errorf("apiResp is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + federationId := "" + if apiResp.Id != nil { + federationId = *apiResp.Id + } + model.Id = utils.BuildInternalTerraformId(projectId, serviceAccountEmail, federationId) + model.ProjectId = types.StringValue(projectId) + model.ServiceAccountEmail = types.StringValue(serviceAccountEmail) + if federationId != "" { + model.FederationId = types.StringValue(federationId) + } else { + model.FederationId = types.StringNull() + } + + if apiResp.Name != "" { + model.Name = types.StringValue(apiResp.Name) + } else { + model.Name = types.StringNull() + } + + if apiResp.Issuer != "" { + model.Issuer = types.StringValue(apiResp.Issuer) + } else { + model.Issuer = types.StringNull() + } + + // Map assertions + if len(apiResp.Assertions) > 0 { + assertions := make([]AssertionModel, len(apiResp.Assertions)) + for i, assertion := range apiResp.Assertions { + assertions[i] = AssertionModel{ + Item: types.StringValue(assertion.Item), + Operator: types.StringValue(assertion.Operator), + Value: types.StringValue(assertion.Value), + } + } + + assertionsValue, _ := types.ListValueFrom(ctx, types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "item": types.StringType, + "operator": types.StringType, + "value": types.StringType, + }, + }, assertions) + model.Assertions = assertionsValue + } else { + model.Assertions = types.ListNull(types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "item": types.StringType, + "operator": types.StringType, + "value": types.StringType, + }, + }) + } + + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*serviceaccount.CreateFederatedIdentityProviderPayload, error) { + payload := &serviceaccount.CreateFederatedIdentityProviderPayload{ + Name: model.Name.ValueString(), + Issuer: model.Issuer.ValueString(), + } + + if !model.Assertions.IsNull() { + var assertions []AssertionModel + diags := model.Assertions.ElementsAs(ctx, &assertions, false) + if diags.HasError() { + return nil, fmt.Errorf("failed to extract assertions from model") + } + + assertionsPayload := make([]serviceaccount.CreateFederatedIdentityProviderPayloadAssertionsInner, len(assertions)) + for i, assertion := range assertions { + assertionsPayload[i] = serviceaccount.CreateFederatedIdentityProviderPayloadAssertionsInner{ + Item: conversion.StringValueToPointer(assertion.Item), + Operator: conversion.StringValueToPointer(assertion.Operator), + Value: conversion.StringValueToPointer(assertion.Value), + } + } + payload.Assertions = assertionsPayload + } + + return payload, nil +} + +func toUpdatePayload(ctx context.Context, model *Model) (*serviceaccount.PartialUpdateServiceAccountFederatedIdentityProviderPayload, error) { + payload := &serviceaccount.PartialUpdateServiceAccountFederatedIdentityProviderPayload{} + if model == nil { + return nil, fmt.Errorf("model input is nil") + } + if !model.Issuer.IsNull() { + payload.Issuer = model.Issuer.ValueString() + } + if !model.Name.IsNull() { + payload.Name = model.Name.ValueString() + } + if !model.Assertions.IsNull() { + var assertions []AssertionModel + diags := model.Assertions.ElementsAs(ctx, &assertions, false) + if diags.HasError() { + return nil, fmt.Errorf("failed to extract assertions from model") + } + + assertionsPayload := make([]serviceaccount.PartialUpdateServiceAccountFederatedIdentityProviderPayloadAssertionsInner, len(assertions)) + for i, assertion := range assertions { + assertionsPayload[i] = serviceaccount.PartialUpdateServiceAccountFederatedIdentityProviderPayloadAssertionsInner{ + Item: conversion.StringValueToPointer(assertion.Item), + Operator: conversion.StringValueToPointer(assertion.Operator), + Value: conversion.StringValueToPointer(assertion.Value), + } + } + payload.Assertions = assertionsPayload + } + + return payload, nil +} diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go b/stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go new file mode 100644 index 000000000..490295dd2 --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go @@ -0,0 +1,355 @@ +package federated_identity_provider + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + serviceaccount "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount/v2api" +) + +func assertionsObjectType() types.ObjectType { + return types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "item": types.StringType, + "operator": types.StringType, + "value": types.StringType, + }, + } +} + +func assertionsListFromModels(t *testing.T, assertions []AssertionModel) types.List { + t.Helper() + + listValue, diags := types.ListValueFrom(t.Context(), assertionsObjectType(), assertions) + if diags.HasError() { + t.Fatalf("failed to build assertions list: %v", diags.Errors()) + } + return listValue +} + +func ptrString(s string) *string { return &s } + +func TestMapFields(t *testing.T) { + ctx := context.Background() + + tests := []struct { + description string + input *serviceaccount.FederatedIdentityProvider + projectID string + serviceAccountEmail string + expectError bool + expectAssertionsNull bool + expectedAssertions []AssertionModel + }{ + { + description: "default_values", + projectID: "pid", + serviceAccountEmail: "service-account@sa.stackit.cloud", + input: &serviceaccount.FederatedIdentityProvider{ + Id: ptrString("fed-uuid-123"), + Name: "provider-name", + Issuer: "https://issuer.example.com", + Assertions: []serviceaccount.FederatedIdentityProviderAssertionsInner{ + {Item: "iss", Operator: "equals", Value: "https://issuer.example.com"}, + {Item: "sub", Operator: "equals", Value: "user@example.com"}, + }, + }, + expectedAssertions: []AssertionModel{ + {Item: types.StringValue("iss"), Operator: types.StringValue("equals"), Value: types.StringValue("https://issuer.example.com")}, + {Item: types.StringValue("sub"), Operator: types.StringValue("equals"), Value: types.StringValue("user@example.com")}, + }, + }, + { + description: "empty_optional_values", + projectID: "pid", + serviceAccountEmail: "service-account@sa.stackit.cloud", + input: &serviceaccount.FederatedIdentityProvider{}, + expectAssertionsNull: true, + }, + { + description: "nil_response", + input: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + model := &Model{} + + err := mapFields(ctx, tt.input, model, tt.projectID, tt.serviceAccountEmail) + if tt.expectError { + if err == nil { + t.Fatalf("expected error but got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if model.ProjectId.ValueString() != tt.projectID { + t.Fatalf("project_id mismatch: got %q, expected %q", model.ProjectId.ValueString(), tt.projectID) + } + if model.ServiceAccountEmail.ValueString() != tt.serviceAccountEmail { + t.Fatalf("service_account_email mismatch: got %q, expected %q", model.ServiceAccountEmail.ValueString(), tt.serviceAccountEmail) + } + + if tt.description == "default_values" { + if model.Name.ValueString() != "provider-name" { + t.Fatalf("name mismatch: got %q", model.Name.ValueString()) + } + if model.Id.ValueString() != "pid,service-account@sa.stackit.cloud,fed-uuid-123" { + t.Fatalf("id mismatch: got %q", model.Id.ValueString()) + } + if model.FederationId.ValueString() != "fed-uuid-123" { + t.Fatalf("federation_id mismatch: got %q", model.FederationId.ValueString()) + } + if model.Issuer.ValueString() != "https://issuer.example.com" { + t.Fatalf("issuer mismatch: got %q", model.Issuer.ValueString()) + } + } + + if tt.expectAssertionsNull { + if !model.Assertions.IsNull() { + t.Fatalf("expected assertions to be null") + } + if !model.Issuer.IsNull() { + t.Fatalf("expected issuer to be null") + } + return + } + + var mappedAssertions []AssertionModel + diags := model.Assertions.ElementsAs(ctx, &mappedAssertions, false) + if diags.HasError() { + t.Fatalf("failed to decode assertions: %v", diags.Errors()) + } + if len(mappedAssertions) != len(tt.expectedAssertions) { + t.Fatalf("assertions length mismatch: got %d, expected %d", len(mappedAssertions), len(tt.expectedAssertions)) + } + for i := range mappedAssertions { + if mappedAssertions[i].Item.ValueString() != tt.expectedAssertions[i].Item.ValueString() { + t.Fatalf("assertions[%d].item mismatch: got %q, expected %q", i, mappedAssertions[i].Item.ValueString(), tt.expectedAssertions[i].Item.ValueString()) + } + if mappedAssertions[i].Operator.ValueString() != tt.expectedAssertions[i].Operator.ValueString() { + t.Fatalf("assertions[%d].operator mismatch: got %q, expected %q", i, mappedAssertions[i].Operator.ValueString(), tt.expectedAssertions[i].Operator.ValueString()) + } + if mappedAssertions[i].Value.ValueString() != tt.expectedAssertions[i].Value.ValueString() { + t.Fatalf("assertions[%d].value mismatch: got %q, expected %q", i, mappedAssertions[i].Value.ValueString(), tt.expectedAssertions[i].Value.ValueString()) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + validAssertions := []AssertionModel{ + {Item: types.StringValue("iss"), Operator: types.StringValue("equals"), Value: types.StringValue("https://issuer.example.com")}, + {Item: types.StringValue("sub"), Operator: types.StringValue("equals"), Value: types.StringValue("user@example.com")}, + } + + tests := []struct { + description string + model *Model + expectError bool + }{ + { + description: "default_values", + model: &Model{ + Name: types.StringValue("provider-name"), + Issuer: types.StringValue("https://issuer.example.com"), + Assertions: assertionsListFromModels(t, validAssertions), + }, + }, + { + description: "without_assertions", + model: &Model{ + Name: types.StringValue("provider-name"), + Issuer: types.StringValue("https://issuer.example.com"), + Assertions: types.ListNull(types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "item": types.StringType, + "operator": types.StringType, + "value": types.StringType, + }, + }), + }, + }, + { + description: "invalid_assertions_type", + model: &Model{ + Name: types.StringValue("provider-name"), + Issuer: types.StringValue("https://issuer.example.com"), + Assertions: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("not-an-object")}), + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := toCreatePayload(t.Context(), tt.model) + if tt.expectError { + if err == nil { + t.Fatalf("expected error but got nil") + } + if payload != nil { + t.Fatalf("expected nil payload on error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if payload.Name != "provider-name" { + t.Fatalf("name mismatch: got %q", payload.Name) + } + if payload.Issuer != "https://issuer.example.com" { + t.Fatalf("issuer mismatch: got %q", payload.Issuer) + } + + switch tt.description { + case "default_values": + if len(payload.Assertions) != 2 { + t.Fatalf("assertions length mismatch: got %d", len(payload.Assertions)) + } + if payload.Assertions[0].Item == nil || *payload.Assertions[0].Item != "iss" { + t.Fatalf("assertions[0].item mismatch") + } + if payload.Assertions[0].Operator == nil || *payload.Assertions[0].Operator != "equals" { + t.Fatalf("assertions[0].operator mismatch") + } + if payload.Assertions[0].Value == nil || *payload.Assertions[0].Value != "https://issuer.example.com" { + t.Fatalf("assertions[0].value mismatch") + } + if payload.Assertions[1].Item == nil || *payload.Assertions[1].Item != "sub" { + t.Fatalf("assertions[1].item mismatch") + } + if payload.Assertions[1].Operator == nil || *payload.Assertions[1].Operator != "equals" { + t.Fatalf("assertions[1].operator mismatch") + } + if payload.Assertions[1].Value == nil || *payload.Assertions[1].Value != "user@example.com" { + t.Fatalf("assertions[1].value mismatch") + } + case "without_assertions": + if len(payload.Assertions) != 0 { + t.Fatalf("expected no assertions, got %d", len(payload.Assertions)) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + validAssertions := []AssertionModel{ + {Item: types.StringValue("aud"), Operator: types.StringValue("equals"), Value: types.StringValue("https://example.com")}, + {Item: types.StringValue("sub"), Operator: types.StringValue("equals"), Value: types.StringValue("user@example.com")}, + } + + tests := []struct { + description string + model *Model + expectError bool + }{ + { + description: "all_fields_set", + model: &Model{ + Name: types.StringValue("provider-name"), + Issuer: types.StringValue("https://issuer.example.com"), + Assertions: assertionsListFromModels(t, validAssertions), + }, + }, + { + description: "null_assertions_replaces_external", + model: &Model{ + Name: types.StringValue("provider-name"), + Issuer: types.StringValue("https://issuer.example.com"), + Assertions: types.ListNull(types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "item": types.StringType, + "operator": types.StringType, + "value": types.StringType, + }, + }), + }, + }, + { + description: "null_issuer_and_name", + model: &Model{ + Name: types.StringNull(), + Issuer: types.StringNull(), + Assertions: assertionsListFromModels(t, validAssertions[:1]), + }, + }, + { + description: "invalid_assertions_type", + model: &Model{ + Name: types.StringValue("provider-name"), + Issuer: types.StringValue("https://issuer.example.com"), + Assertions: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("not-an-object")}), + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := toUpdatePayload(t.Context(), tt.model) + if tt.expectError { + if err == nil { + t.Fatalf("expected error but got nil") + } + if payload != nil { + t.Fatalf("expected nil payload on error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + switch tt.description { + case "all_fields_set": + if payload.Name != "provider-name" { + t.Fatalf("name mismatch: got %q", payload.Name) + } + if payload.Issuer != "https://issuer.example.com" { + t.Fatalf("issuer mismatch: got %q", payload.Issuer) + } + if len(payload.Assertions) != 2 { + t.Fatalf("assertions length mismatch: got %d, expected 2", len(payload.Assertions)) + } + if payload.Assertions[0].Item == nil || *payload.Assertions[0].Item != "aud" { + t.Fatalf("assertions[0].item mismatch") + } + if payload.Assertions[0].Operator == nil || *payload.Assertions[0].Operator != "equals" { + t.Fatalf("assertions[0].operator mismatch") + } + if payload.Assertions[0].Value == nil || *payload.Assertions[0].Value != "https://example.com" { + t.Fatalf("assertions[0].value mismatch") + } + if payload.Assertions[1].Item == nil || *payload.Assertions[1].Item != "sub" { + t.Fatalf("assertions[1].item mismatch") + } + case "null_assertions_replaces_external": + if len(payload.Assertions) != 0 { + t.Fatalf("expected assertions to be empty when null, got %d", len(payload.Assertions)) + } + case "null_issuer_and_name": + if payload.Issuer != "" { + t.Fatalf("expected empty issuer for null, got %q", payload.Issuer) + } + if payload.Name != "" { + t.Fatalf("expected empty name for null, got %q", payload.Name) + } + if len(payload.Assertions) != 1 { + t.Fatalf("assertions length mismatch: got %d, expected 1", len(payload.Assertions)) + } + } + }) + } +} diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/schema_validators.go b/stackit/internal/services/serviceaccount/federated_identity_provider/schema_validators.go new file mode 100644 index 000000000..1785ce10f --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/schema_validators.go @@ -0,0 +1,53 @@ +package federated_identity_provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// assertionsValidator implements the validator.List interface. +type assertionsValidator struct{} + +func (v assertionsValidator) Description(_ context.Context) string { + return "Ensure assertions are correct." +} + +func (v assertionsValidator) MarkdownDescription(_ context.Context) string { + return "Ensure assertions are correct." +} + +func (v assertionsValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { //nolint:gocritic // function signature required by Terraform + // Skip validation when the value is null or unknown, for example during plan with computed values. + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + var assertions []AssertionModel + diags := req.ConfigValue.ElementsAs(ctx, &assertions, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + foundAud := false + for _, assertion := range assertions { + if !assertion.Item.IsNull() && !assertion.Item.IsUnknown() && assertion.Item.ValueString() == "aud" { + foundAud = true + break + } + } + + // If no "aud" assertion is found, return an error pointing to the attribute path. + if !foundAud { + resp.Diagnostics.AddAttributeError( + req.Path, + "Missing Required Assertion", + "The 'assertions' list must contain at least one block where the 'item' field is exactly \"aud\".", + ) + } +} + +// requireAssertions returns the helper validator used in the schema. +func requireAssertions() validator.List { + return assertionsValidator{} +} diff --git a/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go b/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go index e885f61b3..dcf4f6c01 100644 --- a/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go +++ b/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go @@ -22,6 +22,15 @@ var ( //go:embed testdata/resource-service-account.tf resourceServiceAccount string + //go:embed testdata/resource-service-account-federated-identity-provider-without-assertions.tf + resourceServiceAccountFederatedIdentityProviderWithoutAssertions string + + //go:embed testdata/resource-service-account-federated-identity-provider-without-aud.tf + resourceServiceAccountFederatedIdentityProviderWithoutAud string + + //go:embed testdata/resource-service-account-federated-identity-provider.tf + resourceServiceAccountFederatedIdentityProvider string + //go:embed testdata/datasource-service-account.tf datasourceServiceAccount string @@ -36,6 +45,9 @@ var ( //go:embed testdata/datasource-service-account-exact-not-found.tf datasourceServiceAccountExactNotFound string + + //go:embed testdata/datasource-service-account-federated-identity-provider.tf + datasourceServiceAccountFederatedIdentityProvider string ) var testConfigVars = config.Variables{ @@ -66,6 +78,30 @@ var testConfigVarsExactNotFound = config.Variables{ "not_found_email": config.StringVariable("does-not-exist-123@sa.stackit.cloud"), } +var testConfigVarsFederatedIdentityProviderWithoutAssertions = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "provider_name": config.StringVariable("provider-no-assertions"), +} + +var testConfigVarsFederatedIdentityProviderWithoutAud = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "provider_name": config.StringVariable("provider-no-aud"), +} + +var testConfigVarsFederatedIdentityProviderCreate = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "provider_name": config.StringVariable("provider1"), + "sub": config.StringVariable("user@mail.com"), + "name": config.StringVariable("satest03"), +} + +var testConfigVarsFederatedIdentityProviderUpdate = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "provider_name": config.StringVariable("provider1-updated"), + "sub": config.StringVariable("other@mail.com"), + "name": config.StringVariable("satest03"), +} + func TestServiceAccount(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, @@ -182,6 +218,94 @@ func TestServiceAccount(t *testing.T) { ImportState: true, ImportStateVerify: true, }, + // Federated identity provider - Attempt without assertions (should fail) + { + ConfigVariables: testConfigVarsFederatedIdentityProviderWithoutAssertions, + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + resourceServiceAccountFederatedIdentityProviderWithoutAssertions, + ExpectError: regexp.MustCompile(`The argument "assertions" is required`), + }, + // Federated identity provider - Attempt without aud assertion (should fail) + { + ConfigVariables: testConfigVarsFederatedIdentityProviderWithoutAud, + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + resourceServiceAccountFederatedIdentityProviderWithoutAud, + ExpectError: regexp.MustCompile(`Missing Required Assertion`), + }, + // Federated identity provider - Creation with assertions + { + ConfigVariables: testConfigVarsFederatedIdentityProviderCreate, + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + resourceServiceAccountFederatedIdentityProvider, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "name", "provider1"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "issuer", "https://accounts.stackit.cloud"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.#", "3"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.0.item", "iss"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.0.operator", "equals"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.0.value", "https://accounts.stackit.cloud"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.1.item", "sub"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.1.operator", "equals"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.1.value", "user@mail.com"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.2.item", "aud"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.2.operator", "equals"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.2.value", "sts.accounts.stackit.cloud"), + resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "id"), + resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "service_account_email"), + ), + }, + // Federated identity provider - Update with assertions + { + ConfigVariables: testConfigVarsFederatedIdentityProviderUpdate, + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + resourceServiceAccountFederatedIdentityProvider, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "name", "provider1-updated"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "issuer", "https://accounts.stackit.cloud"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.#", "3"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.0.item", "iss"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.0.operator", "equals"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.0.value", "https://accounts.stackit.cloud"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.1.item", "sub"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.1.operator", "equals"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.1.value", "other@mail.com"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.2.item", "aud"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.2.operator", "equals"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.2.value", "sts.accounts.stackit.cloud"), + resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "id"), + resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "service_account_email"), + ), + }, + // Federated identity data source + { + ConfigVariables: testConfigVarsFederatedIdentityProviderUpdate, + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + resourceServiceAccountFederatedIdentityProvider + "\n" + datasourceServiceAccountFederatedIdentityProvider, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.stackit_service_account_federated_identity_provider.provider", "id"), + resource.TestCheckResourceAttrPair( + "stackit_service_account_federated_identity_provider.provider", "project_id", + "data.stackit_service_account_federated_identity_provider.provider", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_service_account_federated_identity_provider.provider", "service_account_email", + "data.stackit_service_account_federated_identity_provider.provider", "service_account_email", + ), + resource.TestCheckResourceAttrPair( + "stackit_service_account_federated_identity_provider.provider", "email", + "data.stackit_service_account_federated_identity_provider.provider", "email", + ), + resource.TestCheckResourceAttrPair( + "stackit_service_account_federated_identity_provider.provider", "name", + "data.stackit_service_account_federated_identity_provider.provider", "name", + ), + resource.TestCheckResourceAttrPair( + "stackit_service_account_federated_identity_provider.provider", "issuer", + "data.stackit_service_account_federated_identity_provider.provider", "issuer", + ), + resource.TestCheckResourceAttrPair( + "stackit_service_account_federated_identity_provider.provider", "assertions", + "data.stackit_service_account_federated_identity_provider.provider", "assertions", + ), + ), + }, // Deletion is done by the framework implicitly }, }) diff --git a/stackit/internal/services/serviceaccount/testdata/datasource-service-account-federated-identity-provider.tf b/stackit/internal/services/serviceaccount/testdata/datasource-service-account-federated-identity-provider.tf new file mode 100644 index 000000000..31a0345c2 --- /dev/null +++ b/stackit/internal/services/serviceaccount/testdata/datasource-service-account-federated-identity-provider.tf @@ -0,0 +1,5 @@ +data "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + federation_id = stackit_service_account_federated_identity_provider.provider.federation_id +} diff --git a/stackit/internal/services/serviceaccount/testdata/resource-service-account-federated-identity-provider-without-assertions.tf b/stackit/internal/services/serviceaccount/testdata/resource-service-account-federated-identity-provider-without-assertions.tf new file mode 100644 index 000000000..454c8a9af --- /dev/null +++ b/stackit/internal/services/serviceaccount/testdata/resource-service-account-federated-identity-provider-without-assertions.tf @@ -0,0 +1,19 @@ +variable "project_id" { + type = string +} + +variable "provider_name" { + type = string +} + +resource "stackit_service_account" "sa" { + project_id = var.project_id + name = "test-sa-no-assertion" +} + +resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = var.provider_name + issuer = "https://account.stackit.cloud" +} diff --git a/stackit/internal/services/serviceaccount/testdata/resource-service-account-federated-identity-provider-without-aud.tf b/stackit/internal/services/serviceaccount/testdata/resource-service-account-federated-identity-provider-without-aud.tf new file mode 100644 index 000000000..f51203879 --- /dev/null +++ b/stackit/internal/services/serviceaccount/testdata/resource-service-account-federated-identity-provider-without-aud.tf @@ -0,0 +1,32 @@ +variable "project_id" { + type = string +} + +variable "provider_name" { + type = string +} + +resource "stackit_service_account" "sa" { + project_id = var.project_id + name = "test-sa-no-aud" +} + +resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = var.provider_name + issuer = "https://account.stackit.cloud" + + assertions = [ + { + item = "iss" + operator = "equals" + value = "https://account.stackit.cloud" + }, + { + item = "sub" + operator = "equals" + value = "user@example.com" + } + ] +} diff --git a/stackit/internal/services/serviceaccount/testdata/resource-service-account-federated-identity-provider.tf b/stackit/internal/services/serviceaccount/testdata/resource-service-account-federated-identity-provider.tf new file mode 100644 index 000000000..017035275 --- /dev/null +++ b/stackit/internal/services/serviceaccount/testdata/resource-service-account-federated-identity-provider.tf @@ -0,0 +1,45 @@ +variable "project_id" { + type = string +} + +variable "provider_name" { + type = string +} + +variable "name" { + type = string +} + +variable "sub" { + type = string +} + +resource "stackit_service_account" "sa" { + project_id = var.project_id + name = var.name +} + +resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = var.provider_name + issuer = "https://accounts.stackit.cloud" + + assertions = [ + { + item = "iss" + operator = "equals" + value = "https://accounts.stackit.cloud" + }, + { + item = "sub" + operator = "equals" + value = var.sub + }, + { + item = "aud" + operator = "equals" + value = "sts.accounts.stackit.cloud" + } + ] +} diff --git a/stackit/provider.go b/stackit/provider.go index 01b56db9c..1dc8da94c 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -107,6 +107,7 @@ import ( serverUpdateSchedule "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serverupdate/schedule" serviceAccount "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/account" serviceAccounts "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/accounts" + serviceAccountFederatedIdentityProvider "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/federated_identity_provider" serviceAccountKey "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/key" exportpolicy "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sfs/export-policy" projectLock "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sfs/project-lock" @@ -689,6 +690,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource serverUpdateSchedule.NewScheduleDataSource, serverUpdateSchedule.NewSchedulesDataSource, serviceAccount.NewServiceAccountDataSource, + serviceAccountFederatedIdentityProvider.NewServiceAccountFederatedIdentityProviderDataSource, serviceAccounts.NewServiceAccountsDataSource, skeCluster.NewClusterDataSource, skeKubernetesVersion.NewKubernetesVersionsDataSource, @@ -782,6 +784,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { serverBackupSchedule.NewScheduleResource, serverUpdateSchedule.NewScheduleResource, serviceAccount.NewServiceAccountResource, + serviceAccountFederatedIdentityProvider.NewServiceAccountFederatedIdentityProviderResource, serviceAccountKey.NewServiceAccountKeyResource, skeCluster.NewClusterResource, skeKubeconfig.NewKubeconfigResource,