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/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/go.mod b/go.mod
index 6b35cb669..175a98b5e 100644
--- a/go.mod
+++ b/go.mod
@@ -36,7 +36,7 @@ require (
github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.14.3
github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.3.8
github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.2.6
- github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.12.0
+ github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.15.0
github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.4.1
github.com/stackitcloud/stackit-sdk-go/services/sfs v0.6.1
github.com/stackitcloud/stackit-sdk-go/services/ske v1.7.0
@@ -46,8 +46,8 @@ require (
)
require (
+ github.com/go-git/go-git/v5 v5.16.5 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
- github.com/kr/text v0.2.0 // indirect
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
)
diff --git a/go.sum b/go.sum
index 5978a96e3..a0af8f586 100644
--- a/go.sum
+++ b/go.sum
@@ -15,7 +15,6 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -31,8 +30,8 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
-github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60=
-github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k=
+github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
+github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -205,6 +204,8 @@ github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.2.6 h1:sQ3fdtUjg
github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.2.6/go.mod h1:3fjlL+9YtuI9Oocl1ZeYIK48ImtY4DwPggFhqAygr7o=
github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.12.0 h1:l1EDIlXce2C8JcbBDHVa6nZ4SjPTqmnALTgrhms+NKI=
github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.12.0/go.mod h1:EXq8/J7t9p8zPmdIq+atuxyAbnQwxrQT18fI+Qpv98k=
+github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.15.0 h1:n+NNJvhJYs7oFuIXZWCnMTHR3dukMXOHXlycBGZ3sEc=
+github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.15.0/go.mod h1:2nXRRpjYPKijMf3muc2fxv46ArqGdpG8IoePS/SUnoQ=
github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.4.1 h1:HZnZju8yqpvRIs71PEk54Jov6p+jiKIIlN+J+4tvcL0=
github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.4.1/go.mod h1:wBxlGcNeQPIh1aS4xYqJuN2z6haSHRwzne6drN5ROfM=
github.com/stackitcloud/stackit-sdk-go/services/sfs v0.6.1 h1:hZSTu3gc31qpStc1Y4DUYF1xFHGBEEVBtUs6tGDLxzQ=
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/mapper_test.go b/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go
new file mode 100644
index 000000000..490295dd2
--- /dev/null
+++ b/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_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/model.go b/stackit/internal/services/serviceaccount/federated_identity_provider/model.go
new file mode 100644
index 000000000..c211f2efc
--- /dev/null
+++ b/stackit/internal/services/serviceaccount/federated_identity_provider/model.go
@@ -0,0 +1,21 @@
+package federated_identity_provider
+
+import "github.com/hashicorp/terraform-plugin-framework/types"
+
+// 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"`
+}
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..77f876182
--- /dev/null
+++ b/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go
@@ -0,0 +1,456 @@
+package federated_identity_provider
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ 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{}
+)
+
+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.ConfigureV2Client(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, "Create", 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 {
+ var oapiErr *oapierror.GenericOpenAPIError
+ ok := errors.As(err, &oapiErr)
+ if ok {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Create", fmt.Sprintf("failed to create federated identity provider: %s", oapiErr.Error()))
+ } else {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Create", fmt.Sprintf("failed to create federated identity provider: %v", err))
+ }
+ return
+ }
+
+ if err := mapFields(ctx, apiResp, &model, projectId, serviceAccountEmail); err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Create", fmt.Sprintf("failed to map response to model: %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.ListFederatedIdentityProviders(ctx, projectId, serviceAccountEmail).
+ Execute()
+ if err != nil {
+ var oapiErr *oapierror.GenericOpenAPIError
+ ok := errors.As(err, &oapiErr)
+ if ok {
+ if oapiErr.StatusCode == 404 || oapiErr.StatusCode == 403 {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Read", fmt.Sprintf("failed to list federated identity providers: %s", oapiErr.Error()))
+ } else {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Read", fmt.Sprintf("failed to list federated identity providers: %v", err))
+ }
+ return
+ }
+
+ if len(apiResp.Resources) == 0 {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+
+ var found *serviceaccount.FederatedIdentityProvider
+ for i, provider := range apiResp.Resources {
+ if provider.Id != nil && *provider.Id == federationId {
+ found = &(apiResp.Resources)[i]
+ break
+ }
+ }
+
+ if found == nil {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+
+ if err := mapFields(ctx, found, &model, projectId, serviceAccountEmail); err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Read", 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, "Update", fmt.Sprintf("failed to build update payload: %v", err))
+ return
+ }
+
+ apiResp, err := r.client.DefaultAPI.PartialUpdateServiceAccountFederatedIdentityProvider(ctx, projectId, serviceAccountEmail, federationId).
+ PartialUpdateServiceAccountFederatedIdentityProviderPayload(*payload).
+ Execute()
+ if err != nil {
+ var oapiErr *oapierror.GenericOpenAPIError
+ ok := errors.As(err, &oapiErr)
+ if ok {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Update", fmt.Sprintf("failed to update federated identity provider: %s", oapiErr.Error()))
+ } else {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Update", fmt.Sprintf("failed to update federated identity provider: %v", err))
+ }
+ return
+ }
+
+ if err := mapFields(ctx, apiResp, &model, projectId, serviceAccountEmail); err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Update", fmt.Sprintf("failed to map response to model: %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 {
+ var oapiErr *oapierror.GenericOpenAPIError
+ ok := errors.As(err, &oapiErr)
+ if ok {
+ if oapiErr.StatusCode == 404 || oapiErr.StatusCode == 403 {
+ return
+ }
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Delete", fmt.Sprintf("failed to delete federated identity provider: %s", oapiErr.Error()))
+ } else {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Delete", fmt.Sprintf("failed to delete federated identity provider: %v", err))
+ }
+ return
+ }
+}
+
+func (r *ServiceAccountFederatedIdentityProviderResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
+}
+
+func mapFields(ctx context.Context, apiResp *serviceaccount.FederatedIdentityProvider, model *Model, projectId, serviceAccountEmail string) error {
+ if apiResp == nil {
+ return fmt.Errorf("apiResp 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.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..6d0a56c55
--- /dev/null
+++ b/stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go
@@ -0,0 +1,217 @@
+package federated_identity_provider_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+ "github.com/hashicorp/terraform-plugin-testing/terraform"
+ "github.com/stackitcloud/stackit-sdk-go/core/config"
+ "github.com/stackitcloud/stackit-sdk-go/core/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
+)
+
+func TestAccServiceAccountFederatedIdentityProvider(t *testing.T) {
+ resource.Test(t, resource.TestCase{
+ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
+ CheckDestroy: testAccCheckFederatedIdentityProviderDestroy,
+ Steps: []resource.TestStep{
+ // Creation
+ {
+ Config: testAccFederatedIdentityProviderConfig("provider1"),
+ 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://example.com"),
+ resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "id"),
+ resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "service_account_email"),
+ ),
+ },
+ // Update
+ {
+ Config: testAccFederatedIdentityProviderConfig("provider1-updated"),
+ 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://example.com"),
+ resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "id"),
+ resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "service_account_email"),
+ ),
+ },
+ // Import
+ {
+ ResourceName: "stackit_service_account_federated_identity_provider.provider",
+ ImportStateIdFunc: func(s *terraform.State) (string, error) {
+ r, ok := s.RootModule().Resources["stackit_service_account_federated_identity_provider.provider"]
+ if !ok {
+ return "", fmt.Errorf("couldn't find resource stackit_service_account_federated_identity_provider.provider")
+ }
+ id, ok := r.Primary.Attributes["id"]
+ if !ok {
+ return "", fmt.Errorf("couldn't find attribute id")
+ }
+ return id, nil
+ },
+ ImportState: true,
+ ImportStateVerify: true,
+ },
+ },
+ })
+}
+
+func TestAccServiceAccountFederatedIdentityProviderWithAssertions(t *testing.T) {
+ resource.Test(t, resource.TestCase{
+ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
+ CheckDestroy: testAccCheckFederatedIdentityProviderDestroy,
+ Steps: []resource.TestStep{
+ // Creation with assertions
+ {
+ Config: testAccFederatedIdentityProviderConfigWithAssertions(),
+ 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", "provider-with-assertions"),
+ resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "issuer", "https://example.com"),
+ resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.#", "2"),
+ 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://example.com"),
+ 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@example.com"),
+ resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "id"),
+ ),
+ },
+ },
+ })
+}
+
+func testAccFederatedIdentityProviderConfig(name string) string {
+ return fmt.Sprintf(`
+ %s
+
+ resource "stackit_service_account" "sa" {
+ project_id = "%s"
+ name = "test-sa"
+ }
+
+ 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 = "%s"
+ issuer = "https://example.com"
+ }
+ `, testutil.ServiceAccountProviderConfig(), testutil.ProjectId, name)
+}
+
+func testAccFederatedIdentityProviderConfigWithAssertions() string {
+ return fmt.Sprintf(`
+ %s
+
+ resource "stackit_service_account" "sa" {
+ project_id = "%s"
+ name = "test-sa-with-assertions"
+ }
+
+ 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 = "provider-with-assertions"
+ issuer = "https://example.com"
+
+ assertions = [
+ {
+ item = "iss"
+ operator = "equals"
+ value = "https://example.com"
+ },
+ {
+ item = "sub"
+ operator = "equals"
+ value = "user@example.com"
+ }
+ ]
+ }
+ `, testutil.ServiceAccountProviderConfig(), testutil.ProjectId)
+}
+
+func testAccCheckFederatedIdentityProviderDestroy(s *terraform.State) error {
+ ctx := context.Background()
+ var client *serviceaccount.APIClient
+ var err error
+
+ if testutil.ServiceAccountCustomEndpoint == "" {
+ client, err = serviceaccount.NewAPIClient()
+ } else {
+ client, err = serviceaccount.NewAPIClient(
+ config.WithEndpoint(testutil.ServiceAccountCustomEndpoint),
+ )
+ }
+
+ if err != nil {
+ return fmt.Errorf("creating client: %w", err)
+ }
+
+ var providersToDestroy []string
+ for _, rs := range s.RootModule().Resources {
+ if rs.Type != "stackit_service_account_federated_identity_provider" {
+ continue
+ }
+
+ serviceAccountEmail, ok := rs.Primary.Attributes["service_account_email"]
+ if !ok || serviceAccountEmail == "" {
+ continue
+ }
+
+ providerName, ok := rs.Primary.Attributes["name"]
+ if !ok || providerName == "" {
+ continue
+ }
+
+ key := fmt.Sprintf("%s|%s", serviceAccountEmail, providerName)
+ providersToDestroy = append(providersToDestroy, key)
+ }
+
+ // Check if any providers still exist
+ listResp, err := client.ListServiceAccounts(ctx, testutil.ProjectId).Execute()
+ if err != nil {
+ return fmt.Errorf("getting service accounts: %w", err)
+ }
+
+ if listResp.Items == nil {
+ return nil
+ }
+
+ for _, acc := range *listResp.Items {
+ if acc.Email == nil {
+ continue
+ }
+
+ providersResp, err := client.ListFederatedIdentityProviders(ctx, testutil.ProjectId, *acc.Email).Execute()
+ if err != nil {
+ // Ignore errors, provider might not exist
+ continue
+ }
+
+ if providersResp.Resources == nil {
+ continue
+ }
+
+ for _, provider := range *providersResp.Resources {
+ if provider.Name == nil {
+ continue
+ }
+
+ key := fmt.Sprintf("%s|%s", *acc.Email, *provider.Name)
+ if utils.Contains(providersToDestroy, key) {
+ err := client.DeleteServiceFederatedIdentityProvider(ctx, testutil.ProjectId, *acc.Email, *provider.Name).Execute()
+ if err != nil {
+ return fmt.Errorf("destroying federated identity provider %s during CheckDestroy: %w", *provider.Name, err)
+ }
+ }
+ }
+ }
+
+ return nil
+}
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..6a2bec720
--- /dev/null
+++ b/stackit/internal/services/serviceaccount/federated_identity_provider/schema_validators.go
@@ -0,0 +1,62 @@
+package federated_identity_provider
+
+import (
+ "context"
+
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+)
+
+// 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
+ }
+
+ // Define a lightweight model to extract only the "item" field from list objects.
+ type assertionModel struct {
+ Item types.String `tfsdk:"item"`
+ Operator types.String `tfsdk:"operator"`
+ Value types.String `tfsdk:"value"`
+ }
+
+ 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/utils/util.go b/stackit/internal/services/serviceaccount/utils/util.go
index c2aff4ff4..6efc546a1 100644
--- a/stackit/internal/services/serviceaccount/utils/util.go
+++ b/stackit/internal/services/serviceaccount/utils/util.go
@@ -8,10 +8,12 @@ import (
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/services/serviceaccount"
+ v2 "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount/v2api"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
)
+// Deprecated: v1 Will be removed after 2026-09-30
func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *serviceaccount.APIClient {
apiClientConfigOptions := []config.ConfigurationOption{
config.WithCustomAuth(providerData.RoundTripper),
@@ -28,6 +30,22 @@ func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags
return apiClient
}
+func ConfigureV2Client(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *v2.APIClient {
+ apiClientConfigOptions := []config.ConfigurationOption{
+ config.WithCustomAuth(providerData.RoundTripper),
+ utils.UserAgentConfigOption(providerData.Version),
+ }
+ if providerData.ServiceAccountCustomEndpoint != "" {
+ apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.ServiceAccountCustomEndpoint))
+ }
+ apiClient, err := v2.NewAPIClient(apiClientConfigOptions...)
+ if err != nil {
+ core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
+ return nil
+ }
+
+ return apiClient
+}
// ParseNameFromEmail extracts the name component from a service account email address.
// The expected email format is `name-@sa.stackit.cloud`
diff --git a/stackit/provider.go b/stackit/provider.go
index c30c35839..e6852d3d6 100644
--- a/stackit/provider.go
+++ b/stackit/provider.go
@@ -101,6 +101,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"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sfs/resourcepool"
@@ -761,6 +762,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource {
serverBackupSchedule.NewScheduleResource,
serverUpdateSchedule.NewScheduleResource,
serviceAccount.NewServiceAccountResource,
+ serviceAccountFederatedIdentityProvider.NewServiceAccountFederatedIdentityProviderResource,
serviceAccountKey.NewServiceAccountKeyResource,
skeCluster.NewClusterResource,
skeKubeconfig.NewKubeconfigResource,