From 09f35736edef3ae1c4ab71b72fda28c9ddcb9647 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Thu, 19 Mar 2026 20:27:19 +0100 Subject: [PATCH 01/10] feat: Support Federated Identity Providers for service accounts Signed-off-by: Jorge Turrado --- ...ice_account_federated_identity_provider.md | 100 ++++ go.mod | 4 +- go.sum | 7 +- .../federated_identity_provider/const.go | 37 ++ .../mapper_test.go | 259 ++++++++++ .../federated_identity_provider/model.go | 23 + .../federated_identity_provider/resource.go | 450 ++++++++++++++++++ .../resource_test.go | 221 +++++++++ .../schema_validators.go | 62 +++ .../services/serviceaccount/utils/util.go | 18 + stackit/provider.go | 2 + 11 files changed, 1178 insertions(+), 5 deletions(-) create mode 100644 docs/resources/service_account_federated_identity_provider.md create mode 100644 stackit/internal/services/serviceaccount/federated_identity_provider/const.go create mode 100644 stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go create mode 100644 stackit/internal/services/serviceaccount/federated_identity_provider/model.go create mode 100644 stackit/internal/services/serviceaccount/federated_identity_provider/resource.go create mode 100644 stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go create mode 100644 stackit/internal/services/serviceaccount/federated_identity_provider/schema_validators.go 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..11ed2dd99 --- /dev/null +++ b/docs/resources/service_account_federated_identity_provider.md @@ -0,0 +1,100 @@ +--- +# 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 = "iss" + operator = "EQUALS" + value = "https://auth.example.com" + }, + { + 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 = "iss" + operator = "EQUALS" + value = "https://auth.example.com" + }, + { + item = "email" + operator = "EQUALS" + value = "terraform@example.com" + } + ] +} + +``` + + + + +## Schema + +### Required + +- `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. + +### Optional + +- `assertions` (Attributes List) The assertions for the federated identity provider. (see [below for nested schema](#nestedatt--assertions)) + +### Read-Only + +- `created_at` (String) The timestamp when the federated identity provider was created. +- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`service_account_email`,`federation_id`". +- `updated_at` (String) The timestamp when the federated identity provider was last updated. + + +### Nested Schema for `assertions` + +Optional: + +- `item` (String) The assertion claim. +- `operator` (String) The assertion operator. +- `value` (String) The assertion value. 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..3f2bde94e --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/const.go @@ -0,0 +1,37 @@ +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 = "iss" + operator = "EQUALS" + value = "https://auth.example.com" + }, + { + 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..30e3f32e1 --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go @@ -0,0 +1,259 @@ +package federated_identity_provider + +import ( + "context" + "testing" + "time" + + "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, ctx context.Context, assertions []AssertionModel) types.List { + t.Helper() + + listValue, diags := types.ListValueFrom(ctx, assertionsObjectType(), assertions) + if diags.HasError() { + t.Fatalf("failed to build assertions list: %v", diags.Errors()) + } + return listValue +} + +func TestMapFields(t *testing.T) { + ctx := context.Background() + + createdAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + updatedAt := time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC) + + 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{ + Name: "provider-name", + Issuer: "https://issuer.example.com", + CreatedAt: createdAt, + UpdatedAt: updatedAt, + 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,provider-name" { + t.Fatalf("id mismatch: got %q", model.Id.ValueString()) + } + if model.Issuer.ValueString() != "https://issuer.example.com" { + t.Fatalf("issuer mismatch: got %q", model.Issuer.ValueString()) + } + if model.CreatedAt.ValueString() != createdAt.Format(time.RFC3339) { + t.Fatalf("created_at mismatch: got %q", model.CreatedAt.ValueString()) + } + if model.UpdatedAt.ValueString() != updatedAt.Format(time.RFC3339) { + t.Fatalf("updated_at mismatch: got %q", model.UpdatedAt.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") + } + if !model.CreatedAt.IsNull() { + t.Fatalf("expected created_at to be null") + } + if !model.UpdatedAt.IsNull() { + t.Fatalf("expected updated_at 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) { + ctx := context.Background() + + 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, ctx, 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(ctx, 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)) + } + } + }) + } +} 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..9ecff4d84 --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/model.go @@ -0,0 +1,23 @@ +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"` + CreatedAt types.String `tfsdk:"created_at"` + UpdatedAt types.String `tfsdk:"updated_at"` +} + +// 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..f77de5f76 --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go @@ -0,0 +1,450 @@ +package federated_identity_provider + +import ( + "context" + "fmt" + "time" + + 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(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_account_federated_identity_provider" +} + +func (r *ServiceAccountFederatedIdentityProviderResource) Schema(ctx context.Context, req 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.", + "created_at": "The timestamp when the federated identity provider was created.", + "updated_at": "The timestamp when the federated identity provider was last updated.", + "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.", + "assertions.operator": "The assertion operator.", + "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"], + 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, + }, + "name": schema.StringAttribute{ + Required: true, + Description: descriptions["name"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "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), + requireAssertions(), + }, + Description: descriptions["assertions"], + }, + "created_at": schema.StringAttribute{ + Computed: true, + Description: descriptions["created_at"], + }, + "updated_at": schema.StringAttribute{ + Computed: true, + Description: descriptions["updated_at"], + }, + }, + } +} + +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) { + 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 { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) + 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) { + 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 { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) + 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 != "" && *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) { + // 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 := 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() { + resp.Diagnostics.Append(diags...) + return + } + + 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 + } + + apiResp, err := r.client.DefaultAPI.PartialUpdateServiceAccountFederatedIdentityProvider(ctx, projectId, serviceAccountEmail, federationId). + PartialUpdateServiceAccountFederatedIdentityProviderPayload(payload). + Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) + 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) { + 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 { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) + 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") + } + model.Id = utils.BuildInternalTerraformId(projectId, serviceAccountEmail, *apiResp.Id) + model.ProjectId = types.StringValue(projectId) + model.ServiceAccountEmail = types.StringValue(serviceAccountEmail) + model.FederationId = types.StringValue(*apiResp.Id) + + if apiResp.Name != "" { + model.Name = types.StringValue(apiResp.Name) + } + + if apiResp.Issuer != "" { + model.Issuer = types.StringValue(apiResp.Issuer) + } else { + model.Issuer = types.StringNull() + } + + if !apiResp.CreatedAt.IsZero() { + model.CreatedAt = types.StringValue(apiResp.CreatedAt.Format(time.RFC3339)) + } else { + model.CreatedAt = types.StringNull() + } + + if !apiResp.UpdatedAt.IsZero() { + model.UpdatedAt = types.StringValue(apiResp.UpdatedAt.Format(time.RFC3339)) + } else { + model.UpdatedAt = 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 +} 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..8813fd834 --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go @@ -0,0 +1,221 @@ +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"), + resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "created_at"), + resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "updated_at"), + ), + }, + // 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"), + resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "created_at"), + resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "updated_at"), + ), + }, + // 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..be2dd3fe7 --- /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(ctx context.Context) string { + return "Ensure assertions are correct." +} + +func (v assertionsValidator) MarkdownDescription(ctx context.Context) string { + return "Ensure assertions are correct." +} + +func (v assertionsValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + // 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, From 8572fd594a732ee1ffb6d6f360f520acf59a88e0 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Thu, 19 Mar 2026 20:49:49 +0100 Subject: [PATCH 02/10] update docs and remove not needed fields Signed-off-by: Jorge Turrado --- ...ice_account_federated_identity_provider.md | 28 ++-- .../federated_identity_provider/const.go | 6 +- .../mapper_test.go | 140 +++++++++++++++--- .../federated_identity_provider/model.go | 2 - .../federated_identity_provider/resource.go | 102 ++++++------- .../resource_test.go | 4 - 6 files changed, 188 insertions(+), 94 deletions(-) diff --git a/docs/resources/service_account_federated_identity_provider.md b/docs/resources/service_account_federated_identity_provider.md index 11ed2dd99..36dd94cb6 100644 --- a/docs/resources/service_account_federated_identity_provider.md +++ b/docs/resources/service_account_federated_identity_provider.md @@ -19,14 +19,19 @@ description: |- 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 = "iss" - operator = "EQUALS" + operator = "equals" value = "https://auth.example.com" }, { item = "email" - operator = "EQUALS" + operator = "equals" value = "terraform@example.com" } ] @@ -53,14 +58,19 @@ resource "stackit_service_account_federated_identity_provider" "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 = "iss" - operator = "EQUALS" + operator = "equals" value = "https://auth.example.com" }, { item = "email" - operator = "EQUALS" + operator = "equals" value = "terraform@example.com" } ] @@ -75,25 +85,21 @@ resource "stackit_service_account_federated_identity_provider" "provider" { ### 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. -### Optional - -- `assertions` (Attributes List) The assertions for the federated identity provider. (see [below for nested schema](#nestedatt--assertions)) - ### Read-Only -- `created_at` (String) The timestamp when the federated identity provider was created. +- `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`". -- `updated_at` (String) The timestamp when the federated identity provider was last updated. ### Nested Schema for `assertions` -Optional: +Required: - `item` (String) The assertion claim. - `operator` (String) The assertion operator. diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/const.go b/stackit/internal/services/serviceaccount/federated_identity_provider/const.go index 3f2bde94e..5181c7446 100644 --- a/stackit/internal/services/serviceaccount/federated_identity_provider/const.go +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/const.go @@ -19,17 +19,17 @@ resource "stackit_service_account_federated_identity_provider" "provider" { assertions = [ { item = "aud" # Including the audience check is mandatory for security reasons, the value is free to choose - operator = "EQUALS" + operator = "equals" value = "sts.accounts.stackit.cloud" }, { item = "iss" - operator = "EQUALS" + operator = "equals" value = "https://auth.example.com" }, { item = "email" - operator = "EQUALS" + operator = "equals" value = "terraform@example.com" } ] diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go b/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go index 30e3f32e1..9d5b2a1dd 100644 --- a/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go @@ -3,7 +3,6 @@ package federated_identity_provider import ( "context" "testing" - "time" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" @@ -30,12 +29,11 @@ func assertionsListFromModels(t *testing.T, ctx context.Context, assertions []As return listValue } +func ptrString(s string) *string { return &s } + func TestMapFields(t *testing.T) { ctx := context.Background() - createdAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - updatedAt := time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC) - tests := []struct { description string input *serviceaccount.FederatedIdentityProvider @@ -50,10 +48,9 @@ func TestMapFields(t *testing.T) { projectID: "pid", serviceAccountEmail: "service-account@sa.stackit.cloud", input: &serviceaccount.FederatedIdentityProvider{ - Name: "provider-name", - Issuer: "https://issuer.example.com", - CreatedAt: createdAt, - UpdatedAt: updatedAt, + 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"}, @@ -107,15 +104,12 @@ func TestMapFields(t *testing.T) { if model.Id.ValueString() != "pid,service-account@sa.stackit.cloud,provider-name" { 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 model.CreatedAt.ValueString() != createdAt.Format(time.RFC3339) { - t.Fatalf("created_at mismatch: got %q", model.CreatedAt.ValueString()) - } - if model.UpdatedAt.ValueString() != updatedAt.Format(time.RFC3339) { - t.Fatalf("updated_at mismatch: got %q", model.UpdatedAt.ValueString()) - } } if tt.expectAssertionsNull { @@ -125,12 +119,6 @@ func TestMapFields(t *testing.T) { if !model.Issuer.IsNull() { t.Fatalf("expected issuer to be null") } - if !model.CreatedAt.IsNull() { - t.Fatalf("expected created_at to be null") - } - if !model.UpdatedAt.IsNull() { - t.Fatalf("expected updated_at to be null") - } return } @@ -257,3 +245,115 @@ func TestToCreatePayload(t *testing.T) { }) } } + +func TestToUpdatePayload(t *testing.T) { + ctx := context.Background() + + 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, ctx, 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, ctx, 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(ctx, 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 index 9ecff4d84..c211f2efc 100644 --- a/stackit/internal/services/serviceaccount/federated_identity_provider/model.go +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/model.go @@ -11,8 +11,6 @@ type Model struct { Name types.String `tfsdk:"name"` Issuer types.String `tfsdk:"issuer"` Assertions types.List `tfsdk:"assertions"` - CreatedAt types.String `tfsdk:"created_at"` - UpdatedAt types.String `tfsdk:"updated_at"` } // AssertionModel describes an assertion in the assertions list. diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go b/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go index f77de5f76..e0c47c331 100644 --- a/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go @@ -3,7 +3,6 @@ package federated_identity_provider import ( "context" "fmt" - "time" serviceaccountUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" @@ -51,8 +50,6 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Schema(ctx context.Con "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.", - "created_at": "The timestamp when the federated identity provider was created.", - "updated_at": "The timestamp when the federated identity provider was last updated.", "name": "The name of the federated identity provider.", "issuer": "The issuer URL.", "assertions": "The assertions for the federated identity provider.", @@ -66,6 +63,9 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Schema(ctx context.Con "id": schema.StringAttribute{ Computed: true, Description: descriptions["id"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, Validators: []validator.String{ validate.UUID(), }, @@ -90,13 +90,13 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Schema(ctx context.Con "federation_id": schema.StringAttribute{ Description: descriptions["federation_id"], Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "name": schema.StringAttribute{ Required: true, Description: descriptions["name"], - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, }, "issuer": schema.StringAttribute{ Required: true, @@ -125,18 +125,11 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Schema(ctx context.Con Required: true, Validators: []validator.List{ listvalidator.SizeAtLeast(1), + listvalidator.SizeAtMost(50), // This is the current page limit for assertions. requireAssertions(), }, Description: descriptions["assertions"], }, - "created_at": schema.StringAttribute{ - Computed: true, - Description: descriptions["created_at"], - }, - "updated_at": schema.StringAttribute{ - Computed: true, - Description: descriptions["updated_at"], - }, }, } } @@ -234,7 +227,7 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Read(ctx context.Conte var found *serviceaccount.FederatedIdentityProvider for i, provider := range apiResp.Resources { - if *provider.Id != "" && *provider.Id == federationId { + if provider.Id != nil && *provider.Id == federationId { found = &(apiResp.Resources)[i] break } @@ -277,35 +270,14 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Update(ctx context.Con serviceAccountEmail := model.ServiceAccountEmail.ValueString() federationId := model.FederationId.ValueString() - 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() { - resp.Diagnostics.Append(diags...) - return - } - - 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 + 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). + PartialUpdateServiceAccountFederatedIdentityProviderPayload(*payload). Execute() if err != nil { oapiErr, ok := err.(*oapierror.GenericOpenAPIError) @@ -366,7 +338,11 @@ func mapFields(ctx context.Context, apiResp *serviceaccount.FederatedIdentityPro model.Id = utils.BuildInternalTerraformId(projectId, serviceAccountEmail, *apiResp.Id) model.ProjectId = types.StringValue(projectId) model.ServiceAccountEmail = types.StringValue(serviceAccountEmail) - model.FederationId = types.StringValue(*apiResp.Id) + if apiResp.Id != nil { + model.FederationId = types.StringValue(*apiResp.Id) + } else { + model.FederationId = types.StringNull() + } if apiResp.Name != "" { model.Name = types.StringValue(apiResp.Name) @@ -378,18 +354,6 @@ func mapFields(ctx context.Context, apiResp *serviceaccount.FederatedIdentityPro model.Issuer = types.StringNull() } - if !apiResp.CreatedAt.IsZero() { - model.CreatedAt = types.StringValue(apiResp.CreatedAt.Format(time.RFC3339)) - } else { - model.CreatedAt = types.StringNull() - } - - if !apiResp.UpdatedAt.IsZero() { - model.UpdatedAt = types.StringValue(apiResp.UpdatedAt.Format(time.RFC3339)) - } else { - model.UpdatedAt = types.StringNull() - } - // Map assertions if len(apiResp.Assertions) > 0 { assertions := make([]AssertionModel, len(apiResp.Assertions)) @@ -448,3 +412,33 @@ func toCreatePayload(ctx context.Context, model *Model) (*serviceaccount.CreateF 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 index 8813fd834..f17ad16b8 100644 --- a/stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go @@ -27,8 +27,6 @@ func TestAccServiceAccountFederatedIdentityProvider(t *testing.T) { 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"), - resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "created_at"), - resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "updated_at"), ), }, // Update @@ -40,8 +38,6 @@ func TestAccServiceAccountFederatedIdentityProvider(t *testing.T) { 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"), - resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "created_at"), - resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "updated_at"), ), }, // Import From be3c63fb0705e8de5184328c9b9247f15753c684 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Thu, 19 Mar 2026 20:51:29 +0100 Subject: [PATCH 03/10] update docs and remove not needed fields Signed-off-by: Jorge Turrado --- docs/resources/service_account_federated_identity_provider.md | 4 ++-- .../serviceaccount/federated_identity_provider/resource.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/resources/service_account_federated_identity_provider.md b/docs/resources/service_account_federated_identity_provider.md index 36dd94cb6..9d3cd9171 100644 --- a/docs/resources/service_account_federated_identity_provider.md +++ b/docs/resources/service_account_federated_identity_provider.md @@ -101,6 +101,6 @@ resource "stackit_service_account_federated_identity_provider" "provider" { Required: -- `item` (String) The assertion claim. -- `operator` (String) The assertion operator. +- `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/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go b/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go index e0c47c331..08603614b 100644 --- a/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go @@ -53,8 +53,8 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Schema(ctx context.Con "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.", - "assertions.operator": "The assertion operator.", + "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{ From cfb19de23ec43fc5e415dc838612aa1d77ea45fe Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Thu, 19 Mar 2026 20:52:56 +0100 Subject: [PATCH 04/10] update tests Signed-off-by: Jorge Turrado --- .../federated_identity_provider/mapper_test.go | 16 ++++++++-------- .../federated_identity_provider/resource_test.go | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go b/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go index 9d5b2a1dd..ba45d37cb 100644 --- a/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go @@ -52,13 +52,13 @@ func TestMapFields(t *testing.T) { 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"}, + {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")}, + {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")}, }, }, { @@ -149,8 +149,8 @@ func TestToCreatePayload(t *testing.T) { ctx := context.Background() 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")}, + {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 { @@ -222,7 +222,7 @@ func TestToCreatePayload(t *testing.T) { 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" { + 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" { @@ -231,7 +231,7 @@ func TestToCreatePayload(t *testing.T) { 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" { + 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" { diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go b/stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go index f17ad16b8..6d0a56c55 100644 --- a/stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go @@ -75,10 +75,10 @@ func TestAccServiceAccountFederatedIdentityProviderWithAssertions(t *testing.T) 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.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.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"), ), @@ -123,12 +123,12 @@ func testAccFederatedIdentityProviderConfigWithAssertions() string { assertions = [ { item = "iss" - operator = "EQUALS" + operator = "equals" value = "https://example.com" }, { item = "sub" - operator = "EQUALS" + operator = "equals" value = "user@example.com" } ] From 87b28616959b16e8e7a5760e4e869510b2f40710 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Thu, 19 Mar 2026 20:53:47 +0100 Subject: [PATCH 05/10] update docs Signed-off-by: Jorge Turrado --- .../service_account_federated_identity_provider.md | 10 ---------- .../federated_identity_provider/const.go | 5 ----- 2 files changed, 15 deletions(-) diff --git a/docs/resources/service_account_federated_identity_provider.md b/docs/resources/service_account_federated_identity_provider.md index 9d3cd9171..47a34da6c 100644 --- a/docs/resources/service_account_federated_identity_provider.md +++ b/docs/resources/service_account_federated_identity_provider.md @@ -24,11 +24,6 @@ description: |- operator = "equals" value = "sts.accounts.stackit.cloud" }, - { - item = "iss" - operator = "equals" - value = "https://auth.example.com" - }, { item = "email" operator = "equals" @@ -63,11 +58,6 @@ resource "stackit_service_account_federated_identity_provider" "provider" { operator = "equals" value = "sts.accounts.stackit.cloud" }, - { - item = "iss" - operator = "equals" - value = "https://auth.example.com" - }, { item = "email" operator = "equals" diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/const.go b/stackit/internal/services/serviceaccount/federated_identity_provider/const.go index 5181c7446..e1919e079 100644 --- a/stackit/internal/services/serviceaccount/federated_identity_provider/const.go +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/const.go @@ -22,11 +22,6 @@ resource "stackit_service_account_federated_identity_provider" "provider" { operator = "equals" value = "sts.accounts.stackit.cloud" }, - { - item = "iss" - operator = "equals" - value = "https://auth.example.com" - }, { item = "email" operator = "equals" From 6325b514c5cba059d5999f66490aa8c76a381ce4 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Thu, 19 Mar 2026 21:06:56 +0100 Subject: [PATCH 06/10] fix linter Signed-off-by: Jorge Turrado --- .../mapper_test.go | 18 ++++++------- .../federated_identity_provider/resource.go | 25 +++++++++++-------- .../schema_validators.go | 6 ++--- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go b/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go index ba45d37cb..faa8b47a2 100644 --- a/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go @@ -19,10 +19,10 @@ func assertionsObjectType() types.ObjectType { } } -func assertionsListFromModels(t *testing.T, ctx context.Context, assertions []AssertionModel) types.List { +func assertionsListFromModels(t *testing.T, assertions []AssertionModel) types.List { t.Helper() - listValue, diags := types.ListValueFrom(ctx, assertionsObjectType(), assertions) + listValue, diags := types.ListValueFrom(t.Context(), assertionsObjectType(), assertions) if diags.HasError() { t.Fatalf("failed to build assertions list: %v", diags.Errors()) } @@ -146,8 +146,6 @@ func TestMapFields(t *testing.T) { } func TestToCreatePayload(t *testing.T) { - ctx := context.Background() - 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")}, @@ -163,7 +161,7 @@ func TestToCreatePayload(t *testing.T) { model: &Model{ Name: types.StringValue("provider-name"), Issuer: types.StringValue("https://issuer.example.com"), - Assertions: assertionsListFromModels(t, ctx, validAssertions), + Assertions: assertionsListFromModels(t, validAssertions), }, }, { @@ -193,7 +191,7 @@ func TestToCreatePayload(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - payload, err := toCreatePayload(ctx, tt.model) + payload, err := toCreatePayload(t.Context(), tt.model) if tt.expectError { if err == nil { t.Fatalf("expected error but got nil") @@ -247,8 +245,6 @@ func TestToCreatePayload(t *testing.T) { } func TestToUpdatePayload(t *testing.T) { - ctx := context.Background() - 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")}, @@ -264,7 +260,7 @@ func TestToUpdatePayload(t *testing.T) { model: &Model{ Name: types.StringValue("provider-name"), Issuer: types.StringValue("https://issuer.example.com"), - Assertions: assertionsListFromModels(t, ctx, validAssertions), + Assertions: assertionsListFromModels(t, validAssertions), }, }, { @@ -286,7 +282,7 @@ func TestToUpdatePayload(t *testing.T) { model: &Model{ Name: types.StringNull(), Issuer: types.StringNull(), - Assertions: assertionsListFromModels(t, ctx, validAssertions[:1]), + Assertions: assertionsListFromModels(t, validAssertions[:1]), }, }, { @@ -302,7 +298,7 @@ func TestToUpdatePayload(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - payload, err := toUpdatePayload(ctx, tt.model) + payload, err := toUpdatePayload(t.Context(), tt.model) if tt.expectError { if err == nil { t.Fatalf("expected error but got nil") diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go b/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go index 08603614b..799c8d219 100644 --- a/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go @@ -2,6 +2,7 @@ package federated_identity_provider import ( "context" + "errors" "fmt" serviceaccountUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/utils" @@ -39,11 +40,11 @@ type ServiceAccountFederatedIdentityProviderResource struct { client *serviceaccount.APIClient } -func (r *ServiceAccountFederatedIdentityProviderResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { +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(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { +func (r *ServiceAccountFederatedIdentityProviderResource) Schema(_ context.Context, req 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.", @@ -148,7 +149,7 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Configure(ctx context. tflog.Info(ctx, "Service Account client configured") } -func (r *ServiceAccountFederatedIdentityProviderResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { +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...) @@ -173,7 +174,8 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Create(ctx context.Con CreateFederatedIdentityProviderPayload(*payload). Execute() if err != nil { - oapiErr, ok := err.(*oapierror.GenericOpenAPIError) + 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 { @@ -190,7 +192,7 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Create(ctx context.Con resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) } -func (r *ServiceAccountFederatedIdentityProviderResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { +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...) @@ -207,7 +209,8 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Read(ctx context.Conte apiResp, err := r.client.DefaultAPI.ListFederatedIdentityProviders(ctx, projectId, serviceAccountEmail). Execute() if err != nil { - oapiErr, ok := err.(*oapierror.GenericOpenAPIError) + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) if ok { if oapiErr.StatusCode == 404 || oapiErr.StatusCode == 403 { resp.State.RemoveResource(ctx) @@ -246,7 +249,7 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Read(ctx context.Conte resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) } -func (r *ServiceAccountFederatedIdentityProviderResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { +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) @@ -280,7 +283,8 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Update(ctx context.Con PartialUpdateServiceAccountFederatedIdentityProviderPayload(*payload). Execute() if err != nil { - oapiErr, ok := err.(*oapierror.GenericOpenAPIError) + 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 { @@ -297,7 +301,7 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Update(ctx context.Con resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) } -func (r *ServiceAccountFederatedIdentityProviderResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { +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...) @@ -314,7 +318,8 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Delete(ctx context.Con err := r.client.DefaultAPI.DeleteServiceFederatedIdentityProvider(ctx, projectId, serviceAccountEmail, federationId). Execute() if err != nil { - oapiErr, ok := err.(*oapierror.GenericOpenAPIError) + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) if ok { if oapiErr.StatusCode == 404 || oapiErr.StatusCode == 403 { return diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/schema_validators.go b/stackit/internal/services/serviceaccount/federated_identity_provider/schema_validators.go index be2dd3fe7..6a2bec720 100644 --- a/stackit/internal/services/serviceaccount/federated_identity_provider/schema_validators.go +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/schema_validators.go @@ -10,15 +10,15 @@ import ( // assertionsValidator implements the validator.List interface. type assertionsValidator struct{} -func (v assertionsValidator) Description(ctx context.Context) string { +func (v assertionsValidator) Description(_ context.Context) string { return "Ensure assertions are correct." } -func (v assertionsValidator) MarkdownDescription(ctx context.Context) string { +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) { +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 From e863ad88f0b1309a2db72855390455356ca4afe4 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Thu, 19 Mar 2026 21:12:34 +0100 Subject: [PATCH 07/10] fix tests Signed-off-by: Jorge Turrado --- .../federated_identity_provider/mapper_test.go | 2 +- .../federated_identity_provider/resource.go | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go b/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go index faa8b47a2..490295dd2 100644 --- a/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go @@ -101,7 +101,7 @@ func TestMapFields(t *testing.T) { 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,provider-name" { + 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" { diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go b/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go index 799c8d219..77f876182 100644 --- a/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go @@ -44,7 +44,7 @@ func (r *ServiceAccountFederatedIdentityProviderResource) Metadata(_ context.Con resp.TypeName = req.ProviderTypeName + "_service_account_federated_identity_provider" } -func (r *ServiceAccountFederatedIdentityProviderResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { +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.", @@ -340,17 +340,24 @@ func mapFields(ctx context.Context, apiResp *serviceaccount.FederatedIdentityPro if apiResp == nil { return fmt.Errorf("apiResp is nil") } - model.Id = utils.BuildInternalTerraformId(projectId, serviceAccountEmail, *apiResp.Id) + + 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 apiResp.Id != nil { - model.FederationId = types.StringValue(*apiResp.Id) + 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 != "" { From de650495511a47aaeb775ccfd792f052c190ff32 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Thu, 19 Mar 2026 21:17:16 +0100 Subject: [PATCH 08/10] Add test Signed-off-by: Jorge Turrado --- .../resource.tf | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 examples/resources/stackit_service_account_federated_identity_provider/resource.tf 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..fc1115dfb --- /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}" +} From 808401b151367824d8e9d4c72b4c862526c37006 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Fri, 20 Mar 2026 07:55:20 +0100 Subject: [PATCH 09/10] update docs Signed-off-by: Jorge Turrado --- ...ice_account_federated_identity_provider.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/resources/service_account_federated_identity_provider.md b/docs/resources/service_account_federated_identity_provider.md index 47a34da6c..9a8fcf035 100644 --- a/docs/resources/service_account_federated_identity_provider.md +++ b/docs/resources/service_account_federated_identity_provider.md @@ -68,7 +68,40 @@ resource "stackit_service_account_federated_identity_provider" "provider" { ``` +## 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 From 21cf3d170b15cc1341f8f05c7e3b35eec2c04af8 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Fri, 20 Mar 2026 08:20:10 +0100 Subject: [PATCH 10/10] update docs Signed-off-by: Jorge Turrado --- docs/resources/service_account_federated_identity_provider.md | 2 +- .../resource.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/resources/service_account_federated_identity_provider.md b/docs/resources/service_account_federated_identity_provider.md index 9a8fcf035..f24a97ac7 100644 --- a/docs/resources/service_account_federated_identity_provider.md +++ b/docs/resources/service_account_federated_identity_provider.md @@ -87,7 +87,7 @@ resource "stackit_service_account_federated_identity_provider" "provider" { item = "aud" operator = "equals" value = "sts.accounts.stackit.cloud" - } + }, { item = "sub" operator = "equals" diff --git a/examples/resources/stackit_service_account_federated_identity_provider/resource.tf b/examples/resources/stackit_service_account_federated_identity_provider/resource.tf index fc1115dfb..4a6d44a84 100644 --- a/examples/resources/stackit_service_account_federated_identity_provider/resource.tf +++ b/examples/resources/stackit_service_account_federated_identity_provider/resource.tf @@ -14,7 +14,7 @@ resource "stackit_service_account_federated_identity_provider" "provider" { item = "aud" operator = "equals" value = "sts.accounts.stackit.cloud" - } + }, { item = "sub" operator = "equals"