diff --git a/docs/resources/service_account_federated_identity_provider.md b/docs/resources/service_account_federated_identity_provider.md new file mode 100644 index 000000000..f24a97ac7 --- /dev/null +++ b/docs/resources/service_account_federated_identity_provider.md @@ -0,0 +1,129 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_service_account_federated_identity_provider Resource - stackit" +subcategory: "" +description: |- + Service account federated identity provider schema. + Example Usage + Create a federated identity provider + + resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my-service-account" + } + + resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = "my-provider" + issuer = "https://auth.example.com" + + assertions = [ + { + item = "aud" # Including the audience check is mandatory for security reasons, the value is free to choose + operator = "equals" + value = "sts.accounts.stackit.cloud" + }, + { + item = "email" + operator = "equals" + value = "terraform@example.com" + } + ] + } +--- + +# stackit_service_account_federated_identity_provider (Resource) + +Service account federated identity provider schema. +## Example Usage + + +### Create a federated identity provider +```terraform +resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my-service-account" +} + +resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = "my-provider" + issuer = "https://auth.example.com" + + assertions = [ + { + item = "aud" # Including the audience check is mandatory for security reasons, the value is free to choose + operator = "equals" + value = "sts.accounts.stackit.cloud" + }, + { + item = "email" + operator = "equals" + value = "terraform@example.com" + } + ] +} + +``` + +## Example Usage + +```terraform +resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my-service-account" +} + +resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = "gh-actions" + issuer = "https://token.actions.githubusercontent.com" + + assertions = [ + { + item = "aud" + operator = "equals" + value = "sts.accounts.stackit.cloud" + }, + { + item = "sub" + operator = "equals" + value = "repo:stackitcloud/terraform-provider-stackit:ref:refs/heads/main" + } + ] +} + +# Only use the import statement, if you want to import an existing federated identity provider +import { + to = stackit_service_account_federated_identity_provider.import-example + id = "${var.project_id},${var.service_account_email},${var.federation_id}" +} +``` + + +## Schema + +### Required + +- `assertions` (Attributes List) The assertions for the federated identity provider. (see [below for nested schema](#nestedatt--assertions)) +- `issuer` (String) The issuer URL. +- `name` (String) The name of the federated identity provider. +- `project_id` (String) The STACKIT project ID associated with the service account. +- `service_account_email` (String) The email address associated with the service account, used for account identification and communication. + +### Read-Only + +- `federation_id` (String) The unique identifier for the federated identity provider associated with the service account. +- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`service_account_email`,`federation_id`". + + +### Nested Schema for `assertions` + +Required: + +- `item` (String) The assertion claim. At least one assertion with the claim "aud" is required for security reasons. +- `operator` (String) The assertion operator. Currently, the only supported operator is "equals". +- `value` (String) The assertion value. diff --git a/examples/resources/stackit_service_account_federated_identity_provider/resource.tf b/examples/resources/stackit_service_account_federated_identity_provider/resource.tf new file mode 100644 index 000000000..4a6d44a84 --- /dev/null +++ b/examples/resources/stackit_service_account_federated_identity_provider/resource.tf @@ -0,0 +1,30 @@ +resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my-service-account" +} + +resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = "gh-actions" + issuer = "https://token.actions.githubusercontent.com" + + assertions = [ + { + item = "aud" + operator = "equals" + value = "sts.accounts.stackit.cloud" + }, + { + item = "sub" + operator = "equals" + value = "repo:stackitcloud/terraform-provider-stackit:ref:refs/heads/main" + } + ] +} + +# Only use the import statement, if you want to import an existing federated identity provider +import { + to = stackit_service_account_federated_identity_provider.import-example + id = "${var.project_id},${var.service_account_email},${var.federation_id}" +} diff --git a/go.mod b/go.mod index 6b35cb669..175a98b5e 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.14.3 github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.3.8 github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.2.6 - github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.12.0 + github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.15.0 github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.4.1 github.com/stackitcloud/stackit-sdk-go/services/sfs v0.6.1 github.com/stackitcloud/stackit-sdk-go/services/ske v1.7.0 @@ -46,8 +46,8 @@ require ( ) require ( + github.com/go-git/go-git/v5 v5.16.5 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect - github.com/kr/text v0.2.0 // indirect golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect ) diff --git a/go.sum b/go.sum index 5978a96e3..a0af8f586 100644 --- a/go.sum +++ b/go.sum @@ -15,7 +15,6 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -31,8 +30,8 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= -github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= -github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= +github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= +github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -205,6 +204,8 @@ github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.2.6 h1:sQ3fdtUjg github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.2.6/go.mod h1:3fjlL+9YtuI9Oocl1ZeYIK48ImtY4DwPggFhqAygr7o= github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.12.0 h1:l1EDIlXce2C8JcbBDHVa6nZ4SjPTqmnALTgrhms+NKI= github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.12.0/go.mod h1:EXq8/J7t9p8zPmdIq+atuxyAbnQwxrQT18fI+Qpv98k= +github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.15.0 h1:n+NNJvhJYs7oFuIXZWCnMTHR3dukMXOHXlycBGZ3sEc= +github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.15.0/go.mod h1:2nXRRpjYPKijMf3muc2fxv46ArqGdpG8IoePS/SUnoQ= github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.4.1 h1:HZnZju8yqpvRIs71PEk54Jov6p+jiKIIlN+J+4tvcL0= github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.4.1/go.mod h1:wBxlGcNeQPIh1aS4xYqJuN2z6haSHRwzne6drN5ROfM= github.com/stackitcloud/stackit-sdk-go/services/sfs v0.6.1 h1:hZSTu3gc31qpStc1Y4DUYF1xFHGBEEVBtUs6tGDLxzQ= diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/const.go b/stackit/internal/services/serviceaccount/federated_identity_provider/const.go new file mode 100644 index 000000000..e1919e079 --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/const.go @@ -0,0 +1,32 @@ +package federated_identity_provider + +const markdownDescription = ` +## Example Usage` + "\n" + ` + +### Create a federated identity provider` + "\n" + + "```terraform" + ` +resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my-service-account" +} + +resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = "my-provider" + issuer = "https://auth.example.com" + + assertions = [ + { + item = "aud" # Including the audience check is mandatory for security reasons, the value is free to choose + operator = "equals" + value = "sts.accounts.stackit.cloud" + }, + { + item = "email" + operator = "equals" + value = "terraform@example.com" + } + ] +} +` + "\n```" diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go b/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go new file mode 100644 index 000000000..490295dd2 --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/mapper_test.go @@ -0,0 +1,355 @@ +package federated_identity_provider + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + serviceaccount "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount/v2api" +) + +func assertionsObjectType() types.ObjectType { + return types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "item": types.StringType, + "operator": types.StringType, + "value": types.StringType, + }, + } +} + +func assertionsListFromModels(t *testing.T, assertions []AssertionModel) types.List { + t.Helper() + + listValue, diags := types.ListValueFrom(t.Context(), assertionsObjectType(), assertions) + if diags.HasError() { + t.Fatalf("failed to build assertions list: %v", diags.Errors()) + } + return listValue +} + +func ptrString(s string) *string { return &s } + +func TestMapFields(t *testing.T) { + ctx := context.Background() + + tests := []struct { + description string + input *serviceaccount.FederatedIdentityProvider + projectID string + serviceAccountEmail string + expectError bool + expectAssertionsNull bool + expectedAssertions []AssertionModel + }{ + { + description: "default_values", + projectID: "pid", + serviceAccountEmail: "service-account@sa.stackit.cloud", + input: &serviceaccount.FederatedIdentityProvider{ + Id: ptrString("fed-uuid-123"), + Name: "provider-name", + Issuer: "https://issuer.example.com", + Assertions: []serviceaccount.FederatedIdentityProviderAssertionsInner{ + {Item: "iss", Operator: "equals", Value: "https://issuer.example.com"}, + {Item: "sub", Operator: "equals", Value: "user@example.com"}, + }, + }, + expectedAssertions: []AssertionModel{ + {Item: types.StringValue("iss"), Operator: types.StringValue("equals"), Value: types.StringValue("https://issuer.example.com")}, + {Item: types.StringValue("sub"), Operator: types.StringValue("equals"), Value: types.StringValue("user@example.com")}, + }, + }, + { + description: "empty_optional_values", + projectID: "pid", + serviceAccountEmail: "service-account@sa.stackit.cloud", + input: &serviceaccount.FederatedIdentityProvider{}, + expectAssertionsNull: true, + }, + { + description: "nil_response", + input: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + model := &Model{} + + err := mapFields(ctx, tt.input, model, tt.projectID, tt.serviceAccountEmail) + if tt.expectError { + if err == nil { + t.Fatalf("expected error but got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if model.ProjectId.ValueString() != tt.projectID { + t.Fatalf("project_id mismatch: got %q, expected %q", model.ProjectId.ValueString(), tt.projectID) + } + if model.ServiceAccountEmail.ValueString() != tt.serviceAccountEmail { + t.Fatalf("service_account_email mismatch: got %q, expected %q", model.ServiceAccountEmail.ValueString(), tt.serviceAccountEmail) + } + + if tt.description == "default_values" { + if model.Name.ValueString() != "provider-name" { + t.Fatalf("name mismatch: got %q", model.Name.ValueString()) + } + if model.Id.ValueString() != "pid,service-account@sa.stackit.cloud,fed-uuid-123" { + t.Fatalf("id mismatch: got %q", model.Id.ValueString()) + } + if model.FederationId.ValueString() != "fed-uuid-123" { + t.Fatalf("federation_id mismatch: got %q", model.FederationId.ValueString()) + } + if model.Issuer.ValueString() != "https://issuer.example.com" { + t.Fatalf("issuer mismatch: got %q", model.Issuer.ValueString()) + } + } + + if tt.expectAssertionsNull { + if !model.Assertions.IsNull() { + t.Fatalf("expected assertions to be null") + } + if !model.Issuer.IsNull() { + t.Fatalf("expected issuer to be null") + } + return + } + + var mappedAssertions []AssertionModel + diags := model.Assertions.ElementsAs(ctx, &mappedAssertions, false) + if diags.HasError() { + t.Fatalf("failed to decode assertions: %v", diags.Errors()) + } + if len(mappedAssertions) != len(tt.expectedAssertions) { + t.Fatalf("assertions length mismatch: got %d, expected %d", len(mappedAssertions), len(tt.expectedAssertions)) + } + for i := range mappedAssertions { + if mappedAssertions[i].Item.ValueString() != tt.expectedAssertions[i].Item.ValueString() { + t.Fatalf("assertions[%d].item mismatch: got %q, expected %q", i, mappedAssertions[i].Item.ValueString(), tt.expectedAssertions[i].Item.ValueString()) + } + if mappedAssertions[i].Operator.ValueString() != tt.expectedAssertions[i].Operator.ValueString() { + t.Fatalf("assertions[%d].operator mismatch: got %q, expected %q", i, mappedAssertions[i].Operator.ValueString(), tt.expectedAssertions[i].Operator.ValueString()) + } + if mappedAssertions[i].Value.ValueString() != tt.expectedAssertions[i].Value.ValueString() { + t.Fatalf("assertions[%d].value mismatch: got %q, expected %q", i, mappedAssertions[i].Value.ValueString(), tt.expectedAssertions[i].Value.ValueString()) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + validAssertions := []AssertionModel{ + {Item: types.StringValue("iss"), Operator: types.StringValue("equals"), Value: types.StringValue("https://issuer.example.com")}, + {Item: types.StringValue("sub"), Operator: types.StringValue("equals"), Value: types.StringValue("user@example.com")}, + } + + tests := []struct { + description string + model *Model + expectError bool + }{ + { + description: "default_values", + model: &Model{ + Name: types.StringValue("provider-name"), + Issuer: types.StringValue("https://issuer.example.com"), + Assertions: assertionsListFromModels(t, validAssertions), + }, + }, + { + description: "without_assertions", + model: &Model{ + Name: types.StringValue("provider-name"), + Issuer: types.StringValue("https://issuer.example.com"), + Assertions: types.ListNull(types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "item": types.StringType, + "operator": types.StringType, + "value": types.StringType, + }, + }), + }, + }, + { + description: "invalid_assertions_type", + model: &Model{ + Name: types.StringValue("provider-name"), + Issuer: types.StringValue("https://issuer.example.com"), + Assertions: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("not-an-object")}), + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := toCreatePayload(t.Context(), tt.model) + if tt.expectError { + if err == nil { + t.Fatalf("expected error but got nil") + } + if payload != nil { + t.Fatalf("expected nil payload on error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if payload.Name != "provider-name" { + t.Fatalf("name mismatch: got %q", payload.Name) + } + if payload.Issuer != "https://issuer.example.com" { + t.Fatalf("issuer mismatch: got %q", payload.Issuer) + } + + switch tt.description { + case "default_values": + if len(payload.Assertions) != 2 { + t.Fatalf("assertions length mismatch: got %d", len(payload.Assertions)) + } + if payload.Assertions[0].Item == nil || *payload.Assertions[0].Item != "iss" { + t.Fatalf("assertions[0].item mismatch") + } + if payload.Assertions[0].Operator == nil || *payload.Assertions[0].Operator != "equals" { + t.Fatalf("assertions[0].operator mismatch") + } + if payload.Assertions[0].Value == nil || *payload.Assertions[0].Value != "https://issuer.example.com" { + t.Fatalf("assertions[0].value mismatch") + } + if payload.Assertions[1].Item == nil || *payload.Assertions[1].Item != "sub" { + t.Fatalf("assertions[1].item mismatch") + } + if payload.Assertions[1].Operator == nil || *payload.Assertions[1].Operator != "equals" { + t.Fatalf("assertions[1].operator mismatch") + } + if payload.Assertions[1].Value == nil || *payload.Assertions[1].Value != "user@example.com" { + t.Fatalf("assertions[1].value mismatch") + } + case "without_assertions": + if len(payload.Assertions) != 0 { + t.Fatalf("expected no assertions, got %d", len(payload.Assertions)) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + validAssertions := []AssertionModel{ + {Item: types.StringValue("aud"), Operator: types.StringValue("equals"), Value: types.StringValue("https://example.com")}, + {Item: types.StringValue("sub"), Operator: types.StringValue("equals"), Value: types.StringValue("user@example.com")}, + } + + tests := []struct { + description string + model *Model + expectError bool + }{ + { + description: "all_fields_set", + model: &Model{ + Name: types.StringValue("provider-name"), + Issuer: types.StringValue("https://issuer.example.com"), + Assertions: assertionsListFromModels(t, validAssertions), + }, + }, + { + description: "null_assertions_replaces_external", + model: &Model{ + Name: types.StringValue("provider-name"), + Issuer: types.StringValue("https://issuer.example.com"), + Assertions: types.ListNull(types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "item": types.StringType, + "operator": types.StringType, + "value": types.StringType, + }, + }), + }, + }, + { + description: "null_issuer_and_name", + model: &Model{ + Name: types.StringNull(), + Issuer: types.StringNull(), + Assertions: assertionsListFromModels(t, validAssertions[:1]), + }, + }, + { + description: "invalid_assertions_type", + model: &Model{ + Name: types.StringValue("provider-name"), + Issuer: types.StringValue("https://issuer.example.com"), + Assertions: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("not-an-object")}), + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := toUpdatePayload(t.Context(), tt.model) + if tt.expectError { + if err == nil { + t.Fatalf("expected error but got nil") + } + if payload != nil { + t.Fatalf("expected nil payload on error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + switch tt.description { + case "all_fields_set": + if payload.Name != "provider-name" { + t.Fatalf("name mismatch: got %q", payload.Name) + } + if payload.Issuer != "https://issuer.example.com" { + t.Fatalf("issuer mismatch: got %q", payload.Issuer) + } + if len(payload.Assertions) != 2 { + t.Fatalf("assertions length mismatch: got %d, expected 2", len(payload.Assertions)) + } + if payload.Assertions[0].Item == nil || *payload.Assertions[0].Item != "aud" { + t.Fatalf("assertions[0].item mismatch") + } + if payload.Assertions[0].Operator == nil || *payload.Assertions[0].Operator != "equals" { + t.Fatalf("assertions[0].operator mismatch") + } + if payload.Assertions[0].Value == nil || *payload.Assertions[0].Value != "https://example.com" { + t.Fatalf("assertions[0].value mismatch") + } + if payload.Assertions[1].Item == nil || *payload.Assertions[1].Item != "sub" { + t.Fatalf("assertions[1].item mismatch") + } + case "null_assertions_replaces_external": + if len(payload.Assertions) != 0 { + t.Fatalf("expected assertions to be empty when null, got %d", len(payload.Assertions)) + } + case "null_issuer_and_name": + if payload.Issuer != "" { + t.Fatalf("expected empty issuer for null, got %q", payload.Issuer) + } + if payload.Name != "" { + t.Fatalf("expected empty name for null, got %q", payload.Name) + } + if len(payload.Assertions) != 1 { + t.Fatalf("assertions length mismatch: got %d, expected 1", len(payload.Assertions)) + } + } + }) + } +} diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/model.go b/stackit/internal/services/serviceaccount/federated_identity_provider/model.go new file mode 100644 index 000000000..c211f2efc --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/model.go @@ -0,0 +1,21 @@ +package federated_identity_provider + +import "github.com/hashicorp/terraform-plugin-framework/types" + +// Model describes the resource data model. +type Model struct { + Id types.String `tfsdk:"id"` + ProjectId types.String `tfsdk:"project_id"` + ServiceAccountEmail types.String `tfsdk:"service_account_email"` + FederationId types.String `tfsdk:"federation_id"` + Name types.String `tfsdk:"name"` + Issuer types.String `tfsdk:"issuer"` + Assertions types.List `tfsdk:"assertions"` +} + +// AssertionModel describes an assertion in the assertions list. +type AssertionModel struct { + Item types.String `tfsdk:"item"` + Operator types.String `tfsdk:"operator"` + Value types.String `tfsdk:"value"` +} diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go b/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go new file mode 100644 index 000000000..77f876182 --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/resource.go @@ -0,0 +1,456 @@ +package federated_identity_provider + +import ( + "context" + "errors" + "fmt" + + serviceaccountUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + serviceaccount "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount/v2api" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +var ( + _ resource.Resource = &ServiceAccountFederatedIdentityProviderResource{} + _ resource.ResourceWithConfigure = &ServiceAccountFederatedIdentityProviderResource{} + _ resource.ResourceWithImportState = &ServiceAccountFederatedIdentityProviderResource{} +) + +func NewServiceAccountFederatedIdentityProviderResource() resource.Resource { + return &ServiceAccountFederatedIdentityProviderResource{} +} + +type ServiceAccountFederatedIdentityProviderResource struct { + client *serviceaccount.APIClient +} + +func (r *ServiceAccountFederatedIdentityProviderResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_account_federated_identity_provider" +} + +func (r *ServiceAccountFederatedIdentityProviderResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ + "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`service_account_email`,`federation_id`\".", + "main": "Service account federated identity provider schema.", + "project_id": "The STACKIT project ID associated with the service account.", + "federation_id": "The unique identifier for the federated identity provider associated with the service account.", + "service_account_email": "The email address associated with the service account, used for account identification and communication.", + "name": "The name of the federated identity provider.", + "issuer": "The issuer URL.", + "assertions": "The assertions for the federated identity provider.", + "assertions.item": "The assertion claim. At least one assertion with the claim \"aud\" is required for security reasons.", + "assertions.operator": "The assertion operator. Currently, the only supported operator is \"equals\".", + "assertions.value": "The assertion value.", + } + resp.Schema = schema.Schema{ + MarkdownDescription: fmt.Sprintf("%s%s", descriptions["main"], markdownDescription), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: descriptions["id"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + }, + }, + "project_id": schema.StringAttribute{ + Required: true, + Description: descriptions["project_id"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + }, + }, + "service_account_email": schema.StringAttribute{ + Required: true, + Description: descriptions["service_account_email"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "federation_id": schema.StringAttribute{ + Description: descriptions["federation_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Required: true, + Description: descriptions["name"], + }, + "issuer": schema.StringAttribute{ + Required: true, + Description: descriptions["issuer"], + }, + "assertions": schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "item": schema.StringAttribute{ + Required: true, + Description: descriptions["assertions.item"], + }, + "operator": schema.StringAttribute{ + Required: true, + Description: descriptions["assertions.operator"], + Validators: []validator.String{ + stringvalidator.OneOf("equals"), + }, + }, + "value": schema.StringAttribute{ + Required: true, + Description: descriptions["assertions.value"], + }, + }, + }, + Required: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + listvalidator.SizeAtMost(50), // This is the current page limit for assertions. + requireAssertions(), + }, + Description: descriptions["assertions"], + }, + }, + } +} + +func (r *ServiceAccountFederatedIdentityProviderResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := serviceaccountUtils.ConfigureV2Client(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Service Account client configured") +} + +func (r *ServiceAccountFederatedIdentityProviderResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { //nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + serviceAccountEmail := model.ServiceAccountEmail.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "service_account_email", serviceAccountEmail) + + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Create", fmt.Sprintf("failed to convert model to payload: %v", err)) + return + } + + apiResp, err := r.client.DefaultAPI.CreateFederatedIdentityProvider(ctx, projectId, serviceAccountEmail). + CreateFederatedIdentityProviderPayload(*payload). + Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + if ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Create", fmt.Sprintf("failed to create federated identity provider: %s", oapiErr.Error())) + } else { + core.LogAndAddError(ctx, &resp.Diagnostics, "Create", fmt.Sprintf("failed to create federated identity provider: %v", err)) + } + return + } + + if err := mapFields(ctx, apiResp, &model, projectId, serviceAccountEmail); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Create", fmt.Sprintf("failed to map response to model: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +func (r *ServiceAccountFederatedIdentityProviderResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { //nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + serviceAccountEmail := model.ServiceAccountEmail.ValueString() + federationId := model.FederationId.ValueString() + + apiResp, err := r.client.DefaultAPI.ListFederatedIdentityProviders(ctx, projectId, serviceAccountEmail). + Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + if ok { + if oapiErr.StatusCode == 404 || oapiErr.StatusCode == 403 { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Read", fmt.Sprintf("failed to list federated identity providers: %s", oapiErr.Error())) + } else { + core.LogAndAddError(ctx, &resp.Diagnostics, "Read", fmt.Sprintf("failed to list federated identity providers: %v", err)) + } + return + } + + if len(apiResp.Resources) == 0 { + resp.State.RemoveResource(ctx) + return + } + + var found *serviceaccount.FederatedIdentityProvider + for i, provider := range apiResp.Resources { + if provider.Id != nil && *provider.Id == federationId { + found = &(apiResp.Resources)[i] + break + } + } + + if found == nil { + resp.State.RemoveResource(ctx) + return + } + + if err := mapFields(ctx, found, &model, projectId, serviceAccountEmail); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Read", fmt.Sprintf("failed to map response to model: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +func (r *ServiceAccountFederatedIdentityProviderResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { //nolint:gocritic // function signature required by Terraform + // Read the plan to get the desired configuration + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // federation_id is a computed field only available in the current state, not the plan + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + model.FederationId = stateModel.FederationId + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + serviceAccountEmail := model.ServiceAccountEmail.ValueString() + federationId := model.FederationId.ValueString() + + payload, err := toUpdatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Update", fmt.Sprintf("failed to build update payload: %v", err)) + return + } + + apiResp, err := r.client.DefaultAPI.PartialUpdateServiceAccountFederatedIdentityProvider(ctx, projectId, serviceAccountEmail, federationId). + PartialUpdateServiceAccountFederatedIdentityProviderPayload(*payload). + Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + if ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Update", fmt.Sprintf("failed to update federated identity provider: %s", oapiErr.Error())) + } else { + core.LogAndAddError(ctx, &resp.Diagnostics, "Update", fmt.Sprintf("failed to update federated identity provider: %v", err)) + } + return + } + + if err := mapFields(ctx, apiResp, &model, projectId, serviceAccountEmail); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Update", fmt.Sprintf("failed to map response to model: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +func (r *ServiceAccountFederatedIdentityProviderResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { //nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + serviceAccountEmail := model.ServiceAccountEmail.ValueString() + federationId := model.FederationId.ValueString() + + err := r.client.DefaultAPI.DeleteServiceFederatedIdentityProvider(ctx, projectId, serviceAccountEmail, federationId). + Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + if ok { + if oapiErr.StatusCode == 404 || oapiErr.StatusCode == 403 { + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Delete", fmt.Sprintf("failed to delete federated identity provider: %s", oapiErr.Error())) + } else { + core.LogAndAddError(ctx, &resp.Diagnostics, "Delete", fmt.Sprintf("failed to delete federated identity provider: %v", err)) + } + return + } +} + +func (r *ServiceAccountFederatedIdentityProviderResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func mapFields(ctx context.Context, apiResp *serviceaccount.FederatedIdentityProvider, model *Model, projectId, serviceAccountEmail string) error { + if apiResp == nil { + return fmt.Errorf("apiResp is nil") + } + + federationId := "" + if apiResp.Id != nil { + federationId = *apiResp.Id + } + model.Id = utils.BuildInternalTerraformId(projectId, serviceAccountEmail, federationId) + model.ProjectId = types.StringValue(projectId) + model.ServiceAccountEmail = types.StringValue(serviceAccountEmail) + if federationId != "" { + model.FederationId = types.StringValue(federationId) + } else { + model.FederationId = types.StringNull() + } + + if apiResp.Name != "" { + model.Name = types.StringValue(apiResp.Name) + } else { + model.Name = types.StringNull() + } + + if apiResp.Issuer != "" { + model.Issuer = types.StringValue(apiResp.Issuer) + } else { + model.Issuer = types.StringNull() + } + + // Map assertions + if len(apiResp.Assertions) > 0 { + assertions := make([]AssertionModel, len(apiResp.Assertions)) + for i, assertion := range apiResp.Assertions { + assertions[i] = AssertionModel{ + Item: types.StringValue(assertion.Item), + Operator: types.StringValue(assertion.Operator), + Value: types.StringValue(assertion.Value), + } + } + + assertionsValue, _ := types.ListValueFrom(ctx, types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "item": types.StringType, + "operator": types.StringType, + "value": types.StringType, + }, + }, assertions) + model.Assertions = assertionsValue + } else { + model.Assertions = types.ListNull(types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "item": types.StringType, + "operator": types.StringType, + "value": types.StringType, + }, + }) + } + + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*serviceaccount.CreateFederatedIdentityProviderPayload, error) { + payload := &serviceaccount.CreateFederatedIdentityProviderPayload{ + Name: model.Name.ValueString(), + Issuer: model.Issuer.ValueString(), + } + + if !model.Assertions.IsNull() { + var assertions []AssertionModel + diags := model.Assertions.ElementsAs(ctx, &assertions, false) + if diags.HasError() { + return nil, fmt.Errorf("failed to extract assertions from model") + } + + assertionsPayload := make([]serviceaccount.CreateFederatedIdentityProviderPayloadAssertionsInner, len(assertions)) + for i, assertion := range assertions { + assertionsPayload[i] = serviceaccount.CreateFederatedIdentityProviderPayloadAssertionsInner{ + Item: conversion.StringValueToPointer(assertion.Item), + Operator: conversion.StringValueToPointer(assertion.Operator), + Value: conversion.StringValueToPointer(assertion.Value), + } + } + payload.Assertions = assertionsPayload + } + + return payload, nil +} + +func toUpdatePayload(ctx context.Context, model *Model) (*serviceaccount.PartialUpdateServiceAccountFederatedIdentityProviderPayload, error) { + payload := &serviceaccount.PartialUpdateServiceAccountFederatedIdentityProviderPayload{} + + if !model.Issuer.IsNull() { + payload.Issuer = model.Issuer.ValueString() + } + if !model.Name.IsNull() { + payload.Name = model.Name.ValueString() + } + if !model.Assertions.IsNull() { + var assertions []AssertionModel + diags := model.Assertions.ElementsAs(ctx, &assertions, false) + if diags.HasError() { + return nil, fmt.Errorf("failed to extract assertions from model") + } + + assertionsPayload := make([]serviceaccount.PartialUpdateServiceAccountFederatedIdentityProviderPayloadAssertionsInner, len(assertions)) + for i, assertion := range assertions { + assertionsPayload[i] = serviceaccount.PartialUpdateServiceAccountFederatedIdentityProviderPayloadAssertionsInner{ + Item: conversion.StringValueToPointer(assertion.Item), + Operator: conversion.StringValueToPointer(assertion.Operator), + Value: conversion.StringValueToPointer(assertion.Value), + } + } + payload.Assertions = assertionsPayload + } + + return payload, nil +} diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go b/stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go new file mode 100644 index 000000000..6d0a56c55 --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/resource_test.go @@ -0,0 +1,217 @@ +package federated_identity_provider_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +func TestAccServiceAccountFederatedIdentityProvider(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckFederatedIdentityProviderDestroy, + Steps: []resource.TestStep{ + // Creation + { + Config: testAccFederatedIdentityProviderConfig("provider1"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "name", "provider1"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "issuer", "https://example.com"), + resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "id"), + resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "service_account_email"), + ), + }, + // Update + { + Config: testAccFederatedIdentityProviderConfig("provider1-updated"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "name", "provider1-updated"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "issuer", "https://example.com"), + resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "id"), + resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "service_account_email"), + ), + }, + // Import + { + ResourceName: "stackit_service_account_federated_identity_provider.provider", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_service_account_federated_identity_provider.provider"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_service_account_federated_identity_provider.provider") + } + id, ok := r.Primary.Attributes["id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute id") + } + return id, nil + }, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccServiceAccountFederatedIdentityProviderWithAssertions(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckFederatedIdentityProviderDestroy, + Steps: []resource.TestStep{ + // Creation with assertions + { + Config: testAccFederatedIdentityProviderConfigWithAssertions(), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "name", "provider-with-assertions"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "issuer", "https://example.com"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.#", "2"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.0.item", "iss"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.0.operator", "equals"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.0.value", "https://example.com"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.1.item", "sub"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.1.operator", "equals"), + resource.TestCheckResourceAttr("stackit_service_account_federated_identity_provider.provider", "assertions.1.value", "user@example.com"), + resource.TestCheckResourceAttrSet("stackit_service_account_federated_identity_provider.provider", "id"), + ), + }, + }, + }) +} + +func testAccFederatedIdentityProviderConfig(name string) string { + return fmt.Sprintf(` + %s + + resource "stackit_service_account" "sa" { + project_id = "%s" + name = "test-sa" + } + + resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = "%s" + issuer = "https://example.com" + } + `, testutil.ServiceAccountProviderConfig(), testutil.ProjectId, name) +} + +func testAccFederatedIdentityProviderConfigWithAssertions() string { + return fmt.Sprintf(` + %s + + resource "stackit_service_account" "sa" { + project_id = "%s" + name = "test-sa-with-assertions" + } + + resource "stackit_service_account_federated_identity_provider" "provider" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + name = "provider-with-assertions" + issuer = "https://example.com" + + assertions = [ + { + item = "iss" + operator = "equals" + value = "https://example.com" + }, + { + item = "sub" + operator = "equals" + value = "user@example.com" + } + ] + } + `, testutil.ServiceAccountProviderConfig(), testutil.ProjectId) +} + +func testAccCheckFederatedIdentityProviderDestroy(s *terraform.State) error { + ctx := context.Background() + var client *serviceaccount.APIClient + var err error + + if testutil.ServiceAccountCustomEndpoint == "" { + client, err = serviceaccount.NewAPIClient() + } else { + client, err = serviceaccount.NewAPIClient( + config.WithEndpoint(testutil.ServiceAccountCustomEndpoint), + ) + } + + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + var providersToDestroy []string + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_service_account_federated_identity_provider" { + continue + } + + serviceAccountEmail, ok := rs.Primary.Attributes["service_account_email"] + if !ok || serviceAccountEmail == "" { + continue + } + + providerName, ok := rs.Primary.Attributes["name"] + if !ok || providerName == "" { + continue + } + + key := fmt.Sprintf("%s|%s", serviceAccountEmail, providerName) + providersToDestroy = append(providersToDestroy, key) + } + + // Check if any providers still exist + listResp, err := client.ListServiceAccounts(ctx, testutil.ProjectId).Execute() + if err != nil { + return fmt.Errorf("getting service accounts: %w", err) + } + + if listResp.Items == nil { + return nil + } + + for _, acc := range *listResp.Items { + if acc.Email == nil { + continue + } + + providersResp, err := client.ListFederatedIdentityProviders(ctx, testutil.ProjectId, *acc.Email).Execute() + if err != nil { + // Ignore errors, provider might not exist + continue + } + + if providersResp.Resources == nil { + continue + } + + for _, provider := range *providersResp.Resources { + if provider.Name == nil { + continue + } + + key := fmt.Sprintf("%s|%s", *acc.Email, *provider.Name) + if utils.Contains(providersToDestroy, key) { + err := client.DeleteServiceFederatedIdentityProvider(ctx, testutil.ProjectId, *acc.Email, *provider.Name).Execute() + if err != nil { + return fmt.Errorf("destroying federated identity provider %s during CheckDestroy: %w", *provider.Name, err) + } + } + } + } + + return nil +} diff --git a/stackit/internal/services/serviceaccount/federated_identity_provider/schema_validators.go b/stackit/internal/services/serviceaccount/federated_identity_provider/schema_validators.go new file mode 100644 index 000000000..6a2bec720 --- /dev/null +++ b/stackit/internal/services/serviceaccount/federated_identity_provider/schema_validators.go @@ -0,0 +1,62 @@ +package federated_identity_provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// assertionsValidator implements the validator.List interface. +type assertionsValidator struct{} + +func (v assertionsValidator) Description(_ context.Context) string { + return "Ensure assertions are correct." +} + +func (v assertionsValidator) MarkdownDescription(_ context.Context) string { + return "Ensure assertions are correct." +} + +func (v assertionsValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { //nolint:gocritic // function signature required by Terraform + // Skip validation when the value is null or unknown, for example during plan with computed values. + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + // Define a lightweight model to extract only the "item" field from list objects. + type assertionModel struct { + Item types.String `tfsdk:"item"` + Operator types.String `tfsdk:"operator"` + Value types.String `tfsdk:"value"` + } + + var assertions []assertionModel + diags := req.ConfigValue.ElementsAs(ctx, &assertions, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + foundAud := false + for _, assertion := range assertions { + if !assertion.Item.IsNull() && !assertion.Item.IsUnknown() && assertion.Item.ValueString() == "aud" { + foundAud = true + break + } + } + + // If no "aud" assertion is found, return an error pointing to the attribute path. + if !foundAud { + resp.Diagnostics.AddAttributeError( + req.Path, + "Missing Required Assertion", + "The 'assertions' list must contain at least one block where the 'item' field is exactly \"aud\".", + ) + } +} + +// requireAssertions returns the helper validator used in the schema. +func requireAssertions() validator.List { + return assertionsValidator{} +} diff --git a/stackit/internal/services/serviceaccount/utils/util.go b/stackit/internal/services/serviceaccount/utils/util.go index c2aff4ff4..6efc546a1 100644 --- a/stackit/internal/services/serviceaccount/utils/util.go +++ b/stackit/internal/services/serviceaccount/utils/util.go @@ -8,10 +8,12 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" + v2 "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount/v2api" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" ) +// Deprecated: v1 Will be removed after 2026-09-30 func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *serviceaccount.APIClient { apiClientConfigOptions := []config.ConfigurationOption{ config.WithCustomAuth(providerData.RoundTripper), @@ -28,6 +30,22 @@ func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags return apiClient } +func ConfigureV2Client(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *v2.APIClient { + apiClientConfigOptions := []config.ConfigurationOption{ + config.WithCustomAuth(providerData.RoundTripper), + utils.UserAgentConfigOption(providerData.Version), + } + if providerData.ServiceAccountCustomEndpoint != "" { + apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.ServiceAccountCustomEndpoint)) + } + apiClient, err := v2.NewAPIClient(apiClientConfigOptions...) + if err != nil { + core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return nil + } + + return apiClient +} // ParseNameFromEmail extracts the name component from a service account email address. // The expected email format is `name-@sa.stackit.cloud` diff --git a/stackit/provider.go b/stackit/provider.go index c30c35839..e6852d3d6 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -101,6 +101,7 @@ import ( serverUpdateSchedule "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serverupdate/schedule" serviceAccount "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/account" serviceAccounts "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/accounts" + serviceAccountFederatedIdentityProvider "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/federated_identity_provider" serviceAccountKey "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/key" exportpolicy "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sfs/export-policy" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sfs/resourcepool" @@ -761,6 +762,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { serverBackupSchedule.NewScheduleResource, serverUpdateSchedule.NewScheduleResource, serviceAccount.NewServiceAccountResource, + serviceAccountFederatedIdentityProvider.NewServiceAccountFederatedIdentityProviderResource, serviceAccountKey.NewServiceAccountKeyResource, skeCluster.NewClusterResource, skeKubeconfig.NewKubeconfigResource,