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,