diff --git a/docs/ephemeral-resources/access_token.md b/docs/ephemeral-resources/access_token.md new file mode 100644 index 000000000..35d83a000 --- /dev/null +++ b/docs/ephemeral-resources/access_token.md @@ -0,0 +1,97 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_access_token Ephemeral Resource - stackit" +subcategory: "" +description: |- + Ephemeral resource that generates a short-lived STACKIT access token (JWT) using a service account key. A new token is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation. If a private key is not explicitly provided, the provider attempts to extract it from the service account key instead. Access tokens generated from service account keys expire after 60 minutes. + ~> Service account key credentials must be configured either in the STACKIT provider configuration or via environment variables (see example below). If any other authentication method is configured, this ephemeral resource will fail with an error. + ~> This ephemeral-resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_access_token (Ephemeral Resource) + +Ephemeral resource that generates a short-lived STACKIT access token (JWT) using a service account key. A new token is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation. If a private key is not explicitly provided, the provider attempts to extract it from the service account key instead. Access tokens generated from service account keys expire after 60 minutes. + +~> Service account key credentials must be configured either in the STACKIT provider configuration or via environment variables (see example below). If any other authentication method is configured, this ephemeral resource will fail with an error. + +~> This ephemeral-resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +provider "stackit" { + default_region = "eu01" + service_account_key_path = "/path/to/sa_key.json" + enable_beta_resources = true +} + +ephemeral "stackit_access_token" "example" {} + +locals { + stackit_api_base_url = "https://iaas.api.stackit.cloud" + public_ip_path = "/v2/projects/${var.project_id}/regions/${var.region}/public-ips" + + public_ip_payload = { + labels = { + key = "value" + } + } +} + +# Docs: https://registry.terraform.io/providers/magodo/restful/latest +provider "restful" { + base_url = local.stackit_api_base_url + + security = { + http = { + token = { + token = ephemeral.stackit_access_token.example.access_token + } + } + } +} + +resource "restful_resource" "public_ip_restful" { + path = local.public_ip_path + body = local.public_ip_payload + + read_path = "$(path)/$(body.id)" + update_path = "$(path)/$(body.id)" + update_method = "PATCH" + delete_path = "$(path)/$(body.id)" + delete_method = "DELETE" +} + +# Docs: https://registry.terraform.io/providers/Mastercard/restapi/latest +provider "restapi" { + uri = local.stackit_api_base_url + write_returns_object = true + + headers = { + Authorization = "Bearer ${ephemeral.stackit_access_token.example.access_token}" + Content-Type = "application/json" + } + + create_method = "POST" + update_method = "PATCH" + destroy_method = "DELETE" +} + +resource "restapi_object" "public_ip_restapi" { + path = local.public_ip_path + data = jsonencode(local.public_ip_payload) + + id_attribute = "id" + read_method = "GET" + create_method = "POST" + update_method = "PATCH" + destroy_method = "DELETE" +} +``` + + +## Schema + +### Read-Only + +- `access_token` (String, Sensitive) JWT access token for STACKIT API authentication. diff --git a/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf b/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf new file mode 100644 index 000000000..62ff9f8e5 --- /dev/null +++ b/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf @@ -0,0 +1,68 @@ +provider "stackit" { + default_region = "eu01" + service_account_key_path = "/path/to/sa_key.json" + enable_beta_resources = true +} + +ephemeral "stackit_access_token" "example" {} + +locals { + stackit_api_base_url = "https://iaas.api.stackit.cloud" + public_ip_path = "/v2/projects/${var.project_id}/regions/${var.region}/public-ips" + + public_ip_payload = { + labels = { + key = "value" + } + } +} + +# Docs: https://registry.terraform.io/providers/magodo/restful/latest +provider "restful" { + base_url = local.stackit_api_base_url + + security = { + http = { + token = { + token = ephemeral.stackit_access_token.example.access_token + } + } + } +} + +resource "restful_resource" "public_ip_restful" { + path = local.public_ip_path + body = local.public_ip_payload + + read_path = "$(path)/$(body.id)" + update_path = "$(path)/$(body.id)" + update_method = "PATCH" + delete_path = "$(path)/$(body.id)" + delete_method = "DELETE" +} + +# Docs: https://registry.terraform.io/providers/Mastercard/restapi/latest +provider "restapi" { + uri = local.stackit_api_base_url + write_returns_object = true + + headers = { + Authorization = "Bearer ${ephemeral.stackit_access_token.example.access_token}" + Content-Type = "application/json" + } + + create_method = "POST" + update_method = "PATCH" + destroy_method = "DELETE" +} + +resource "restapi_object" "public_ip_restapi" { + path = local.public_ip_path + data = jsonencode(local.public_ip_payload) + + id_attribute = "id" + read_method = "GET" + create_method = "POST" + update_method = "PATCH" + destroy_method = "DELETE" +} diff --git a/stackit/internal/conversion/conversion.go b/stackit/internal/conversion/conversion.go index 312535f01..a0a4c945b 100644 --- a/stackit/internal/conversion/conversion.go +++ b/stackit/internal/conversion/conversion.go @@ -178,8 +178,22 @@ func ParseProviderData(ctx context.Context, providerData any, diags *diag.Diagno stackitProviderData, ok := providerData.(core.ProviderData) if !ok { - core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", providerData)) + core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Expected configure type core.ProviderData, got %T", providerData)) return core.ProviderData{}, false } return stackitProviderData, true } + +func ParseEphemeralProviderData(ctx context.Context, providerData any, diags *diag.Diagnostics) (core.EphemeralProviderData, bool) { + // Prevent panic if the provider has not been configured. + if providerData == nil { + return core.EphemeralProviderData{}, false + } + + stackitProviderData, ok := providerData.(core.EphemeralProviderData) + if !ok { + core.LogAndAddError(ctx, diags, "Error configuring API client", "Expected configure type core.EphemeralProviderData") + return core.EphemeralProviderData{}, false + } + return stackitProviderData, true +} diff --git a/stackit/internal/conversion/conversion_test.go b/stackit/internal/conversion/conversion_test.go index 1d652aead..08083abb2 100644 --- a/stackit/internal/conversion/conversion_test.go +++ b/stackit/internal/conversion/conversion_test.go @@ -304,3 +304,91 @@ func TestParseProviderData(t *testing.T) { }) } } + +func TestParseEphemeralProviderData(t *testing.T) { + type args struct { + providerData any + } + type want struct { + ok bool + providerData core.EphemeralProviderData + } + tests := []struct { + name string + args args + want want + wantErr bool + }{ + { + name: "provider has not been configured", + args: args{ + providerData: nil, + }, + want: want{ + ok: false, + }, + wantErr: false, + }, + { + name: "invalid provider data", + args: args{ + providerData: struct{}{}, + }, + want: want{ + ok: false, + }, + wantErr: true, + }, + { + name: "valid provider data 1", + args: args{ + providerData: core.EphemeralProviderData{}, + }, + want: want{ + ok: true, + providerData: core.EphemeralProviderData{}, + }, + wantErr: false, + }, + { + name: "valid provider data 2", + args: args{ + providerData: core.EphemeralProviderData{ + PrivateKey: "", + PrivateKeyPath: "/home/dev/foo/private-key.json", + ServiceAccountKey: "", + ServiceAccountKeyPath: "/home/dev/foo/key.json", + TokenCustomEndpoint: "", + }, + }, + want: want{ + ok: true, + providerData: core.EphemeralProviderData{ + PrivateKey: "", + PrivateKeyPath: "/home/dev/foo/private-key.json", + ServiceAccountKey: "", + ServiceAccountKeyPath: "/home/dev/foo/key.json", + TokenCustomEndpoint: "", + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + diags := diag.Diagnostics{} + + actual, ok := ParseEphemeralProviderData(ctx, tt.args.providerData, &diags) + if diags.HasError() != tt.wantErr { + t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr) + } + if ok != tt.want.ok { + t.Errorf("ParseProviderData() got = %v, want %v", ok, tt.want.ok) + } + if !reflect.DeepEqual(actual, tt.want.providerData) { + t.Errorf("ParseProviderData() got = %v, want %v", actual, tt.want) + } + }) + } +} diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index 733274074..e3dd02e0f 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -16,8 +16,9 @@ import ( type ResourceType string const ( - Resource ResourceType = "resource" - Datasource ResourceType = "datasource" + Resource ResourceType = "resource" + Datasource ResourceType = "datasource" + EphemeralResource ResourceType = "ephemeral-resource" // Separator used for concatenation of TF-internal resource ID Separator = "," @@ -26,6 +27,16 @@ const ( DatasourceRegionFallbackDocstring = "Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on datasource level." ) +type EphemeralProviderData struct { + ProviderData + + PrivateKey string + PrivateKeyPath string + ServiceAccountKey string + ServiceAccountKeyPath string + TokenCustomEndpoint string +} + type ProviderData struct { RoundTripper http.RoundTripper ServiceAccountEmail string // Deprecated: ServiceAccountEmail is not required and will be removed after 12th June 2025. diff --git a/stackit/internal/services/access_token/access_token_acc_test.go b/stackit/internal/services/access_token/access_token_acc_test.go new file mode 100644 index 000000000..d544fe39d --- /dev/null +++ b/stackit/internal/services/access_token/access_token_acc_test.go @@ -0,0 +1,50 @@ +package access_token_test + +import ( + _ "embed" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-testing/tfversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +//go:embed testdata/ephemeral_resource.tf +var ephemeralResourceConfig string + +var testConfigVars = config.Variables{ + "default_region": config.StringVariable(testutil.Region), +} + +func TestAccEphemeralAccessToken(t *testing.T) { + resource.Test(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + ProtoV6ProviderFactories: testutil.TestEphemeralAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ephemeralResourceConfig, + ConfigVariables: testConfigVars, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + "echo.example", + tfjsonpath.New("data").AtMapKey("access_token"), + knownvalue.NotNull(), + ), + // JWT access tokens start with "ey" because the first part is base64-encoded JSON that begins with "{". + statecheck.ExpectKnownValue( + "echo.example", + tfjsonpath.New("data").AtMapKey("access_token"), + knownvalue.StringRegexp(regexp.MustCompile(`^ey`)), + ), + }, + }, + }, + }) +} diff --git a/stackit/internal/services/access_token/ephemeral_resource.go b/stackit/internal/services/access_token/ephemeral_resource.go new file mode 100644 index 000000000..8ae346ba3 --- /dev/null +++ b/stackit/internal/services/access_token/ephemeral_resource.go @@ -0,0 +1,132 @@ +package access_token + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/auth" + "github.com/stackitcloud/stackit-sdk-go/core/clients" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "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/features" +) + +var ( + _ ephemeral.EphemeralResource = &accessTokenEphemeralResource{} + _ ephemeral.EphemeralResourceWithConfigure = &accessTokenEphemeralResource{} +) + +func NewAccessTokenEphemeralResource() ephemeral.EphemeralResource { + return &accessTokenEphemeralResource{} +} + +type accessTokenEphemeralResource struct { + keyAuthConfig config.Configuration +} + +func (e *accessTokenEphemeralResource) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { + ephemeralProviderData, ok := conversion.ParseEphemeralProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + features.CheckBetaResourcesEnabled( + ctx, + &ephemeralProviderData.ProviderData, + &resp.Diagnostics, + "stackit_access_token", "ephemeral_resource", + ) + if resp.Diagnostics.HasError() { + return + } + + e.keyAuthConfig = config.Configuration{ + ServiceAccountKey: ephemeralProviderData.ServiceAccountKey, + ServiceAccountKeyPath: ephemeralProviderData.ServiceAccountKeyPath, + PrivateKeyPath: ephemeralProviderData.PrivateKey, + PrivateKey: ephemeralProviderData.PrivateKeyPath, + TokenCustomUrl: ephemeralProviderData.TokenCustomEndpoint, + } +} + +type ephemeralTokenModel struct { + AccessToken types.String `tfsdk:"access_token"` +} + +func (e *accessTokenEphemeralResource) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_access_token" +} + +func (e *accessTokenEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + description := features.AddBetaDescription( + fmt.Sprintf( + "%s\n\n%s", + "Ephemeral resource that generates a short-lived STACKIT access token (JWT) using a service account key. "+ + "A new token is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation. "+ + "If a private key is not explicitly provided, the provider attempts to extract it from the service account key instead. "+ + "Access tokens generated from service account keys expire after 60 minutes.", + "~> Service account key credentials must be configured either in the STACKIT provider configuration or via environment variables (see example below). "+ + "If any other authentication method is configured, this ephemeral resource will fail with an error.", + ), + core.EphemeralResource, + ) + + resp.Schema = schema.Schema{ + Description: description, + Attributes: map[string]schema.Attribute{ + "access_token": schema.StringAttribute{ + Description: "JWT access token for STACKIT API authentication.", + Computed: true, + Sensitive: true, + }, + }, + } +} + +func (e *accessTokenEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + var model ephemeralTokenModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + accessToken, err := getAccessToken(&e.keyAuthConfig) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Access token generation failed", err.Error()) + return + } + + model.AccessToken = types.StringValue(accessToken) + resp.Diagnostics.Append(resp.Result.Set(ctx, model)...) +} + +// getAccessToken initializes authentication using the provided config and returns an access token via the KeyFlow mechanism. +func getAccessToken(keyAuthConfig *config.Configuration) (string, error) { + roundTripper, err := auth.KeyAuth(keyAuthConfig) + if err != nil { + return "", fmt.Errorf( + "failed to initialize authentication: %w. "+ + "Make sure service account credentials are configured either in the provider configuration or via environment variables", + err, + ) + } + + // Type assert to access token functionality + client, ok := roundTripper.(*clients.KeyFlow) + if !ok { + return "", fmt.Errorf("internal error: expected *clients.KeyFlow, but received a different implementation of http.RoundTripper") + } + + // Retrieve the access token + accessToken, err := client.GetAccessToken() + if err != nil { + return "", fmt.Errorf("error obtaining access token: %w", err) + } + + return accessToken, nil +} diff --git a/stackit/internal/services/access_token/ephemeral_resource_test.go b/stackit/internal/services/access_token/ephemeral_resource_test.go new file mode 100644 index 000000000..5df2b91ce --- /dev/null +++ b/stackit/internal/services/access_token/ephemeral_resource_test.go @@ -0,0 +1,253 @@ +package access_token + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + _ "embed" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/clients" + "github.com/stackitcloud/stackit-sdk-go/core/config" +) + +//go:embed testdata/service_account.json +var testServiceAccountKey string + +func startMockTokenServer() *httptest.Server { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := clients.TokenResponseBody{ + AccessToken: "mock_access_token", + RefreshToken: "mock_refresh_token", + TokenType: "Bearer", + ExpiresIn: int(time.Now().Add(time.Hour).Unix()), + Scope: "mock_scope", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + }) + return httptest.NewServer(handler) +} + +func generatePrivateKey() (string, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", err + } + privateKeyPEM := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + } + return string(pem.EncodeToMemory(privateKeyPEM)), nil +} + +func writeTempPEMFile(t *testing.T, pemContent string) string { + t.Helper() + + tmpFile, err := os.CreateTemp("", "stackit_test_private_key_*.pem") + if err != nil { + t.Fatal(err) + } + + if _, err := tmpFile.WriteString(pemContent); err != nil { + t.Fatal(err) + } + + if err := tmpFile.Close(); err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + _ = os.Remove(tmpFile.Name()) + }) + + return tmpFile.Name() +} + +func TestGetAccessToken(t *testing.T) { + mockServer := startMockTokenServer() + t.Cleanup(mockServer.Close) + + privateKey, err := generatePrivateKey() + if err != nil { + t.Fatal(err) + } + + tests := []struct { + description string + setupEnv func() + cleanupEnv func() + cfgFactory func() *config.Configuration + expectError bool + expected string + }{ + { + description: "should return token when service account key passed by value", + cfgFactory: func() *config.Configuration { + return &config.Configuration{ + ServiceAccountKey: testServiceAccountKey, + PrivateKey: privateKey, + TokenCustomUrl: mockServer.URL, + } + }, + expectError: false, + expected: "mock_access_token", + }, + { + description: "should return token when service account key is loaded from file path", + cfgFactory: func() *config.Configuration { + return &config.Configuration{ + ServiceAccountKeyPath: "testdata/service_account.json", + PrivateKey: privateKey, + TokenCustomUrl: mockServer.URL, + } + }, + expectError: false, + expected: "mock_access_token", + }, + { + description: "should fail when private key is invalid", + cfgFactory: func() *config.Configuration { + return &config.Configuration{ + ServiceAccountKey: "invalid-json", + PrivateKey: "invalid-PEM", + TokenCustomUrl: mockServer.URL, + } + }, + expectError: true, + expected: "", + }, + { + description: "should return token when service account key is set via env", + setupEnv: func() { + _ = os.Setenv("STACKIT_SERVICE_ACCOUNT_KEY", testServiceAccountKey) + }, + cleanupEnv: func() { + _ = os.Unsetenv("STACKIT_SERVICE_ACCOUNT_KEY") + }, + cfgFactory: func() *config.Configuration { + return &config.Configuration{ + PrivateKey: privateKey, + TokenCustomUrl: mockServer.URL, + } + }, + expectError: false, + expected: "mock_access_token", + }, + { + description: "should return token when service account key path is set via env", + setupEnv: func() { + _ = os.Setenv("STACKIT_SERVICE_ACCOUNT_KEY_PATH", "testdata/service_account.json") + }, + cleanupEnv: func() { + _ = os.Unsetenv("STACKIT_SERVICE_ACCOUNT_KEY_PATH") + }, + cfgFactory: func() *config.Configuration { + return &config.Configuration{ + PrivateKey: privateKey, + TokenCustomUrl: mockServer.URL, + } + }, + expectError: false, + expected: "mock_access_token", + }, + { + description: "should return token when private key is set via env", + setupEnv: func() { + _ = os.Setenv("STACKIT_PRIVATE_KEY", privateKey) + }, + cleanupEnv: func() { + _ = os.Unsetenv("STACKIT_PRIVATE_KEY") + }, + cfgFactory: func() *config.Configuration { + return &config.Configuration{ + ServiceAccountKey: testServiceAccountKey, + TokenCustomUrl: mockServer.URL, + } + }, + expectError: false, + expected: "mock_access_token", + }, + { + description: "should return token when private key path is set via env", + setupEnv: func() { + // Write temp file and set env + tmpFile := writeTempPEMFile(t, privateKey) + _ = os.Setenv("STACKIT_PRIVATE_KEY_PATH", tmpFile) + }, + cleanupEnv: func() { + _ = os.Unsetenv("STACKIT_PRIVATE_KEY_PATH") + }, + cfgFactory: func() *config.Configuration { + return &config.Configuration{ + ServiceAccountKey: testServiceAccountKey, + TokenCustomUrl: mockServer.URL, + } + }, + expectError: false, + expected: "mock_access_token", + }, + { + description: "should fail when no service account key or private key is set", + cfgFactory: func() *config.Configuration { + return &config.Configuration{ + TokenCustomUrl: mockServer.URL, + } + }, + expectError: true, + expected: "", + }, + { + description: "should fail when no service account key or private key is set via env", + setupEnv: func() { + _ = os.Unsetenv("STACKIT_SERVICE_ACCOUNT_KEY") + _ = os.Unsetenv("STACKIT_SERVICE_ACCOUNT_KEY_PATH") + _ = os.Unsetenv("STACKIT_PRIVATE_KEY") + _ = os.Unsetenv("STACKIT_PRIVATE_KEY_PATH") + }, + cleanupEnv: func() { + // Restore original environment variables + }, + cfgFactory: func() *config.Configuration { + return &config.Configuration{ + TokenCustomUrl: mockServer.URL, + } + }, + expectError: true, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + if tt.setupEnv != nil { + tt.setupEnv() + } + if tt.cleanupEnv != nil { + defer tt.cleanupEnv() + } + + cfg := tt.cfgFactory() + + token, err := getAccessToken(cfg) + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none for test case '%s'", tt.description) + } + } else { + if err != nil { + t.Errorf("did not expect error but got: %v for test case '%s'", err, tt.description) + } + if token != tt.expected { + t.Errorf("expected token '%s', got '%s' for test case '%s'", tt.expected, token, tt.description) + } + } + }) + } +} diff --git a/stackit/internal/services/access_token/testdata/ephemeral_resource.tf b/stackit/internal/services/access_token/testdata/ephemeral_resource.tf new file mode 100644 index 000000000..3d5731a30 --- /dev/null +++ b/stackit/internal/services/access_token/testdata/ephemeral_resource.tf @@ -0,0 +1,15 @@ +variable "default_region" {} + +provider "stackit" { + default_region = var.default_region + enable_beta_resources = true +} + +ephemeral "stackit_access_token" "example" {} + +provider "echo" { + data = ephemeral.stackit_access_token.example +} + +resource "echo" "example" { +} diff --git a/stackit/internal/services/access_token/testdata/service_account.json b/stackit/internal/services/access_token/testdata/service_account.json new file mode 100644 index 000000000..62df6f446 --- /dev/null +++ b/stackit/internal/services/access_token/testdata/service_account.json @@ -0,0 +1,16 @@ +{ + "id": "cad1592f-1fe6-4fd1-a6d6-ccef94b01697", + "publicKey": "-----BEGIN PUBLIC KEY-----\nABC\n-----END PUBLIC KEY-----", + "createdAt": "2025-11-25T15:19:30.689+00:00", + "keyType": "USER_MANAGED", + "keyOrigin": "GENERATED", + "keyAlgorithm": "RSA_2048", + "active": true, + "credentials": { + "kid": "cad1592f-1fe6-4fd1-a6d6-ccef94b01697", + "iss": "foo.bar@sa.stackit.cloud", + "sub": "cad1592f-1fe6-4fd1-a6d6-ccef94b01697", + "aud": "https://stackit-service-account-prod.apps.01.cf.eu01.stackit.cloud", + "privateKey": "-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----" + } +} \ No newline at end of file diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index ea15ce31b..c7e5654aa 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/echoprovider" "github.com/stackitcloud/terraform-provider-stackit/stackit" ) @@ -29,6 +30,18 @@ var ( "stackit": providerserver.NewProtocol6WithError(stackit.New("test-version")()), } + // TestEphemeralAccProtoV6ProviderFactories is used to instantiate a provider during + // acceptance testing. The factory function will be invoked for every Terraform + // CLI command executed to create a provider server to which the CLI can + // reattach. + // + // See the Terraform acceptance test documentation on ephemeral resources for more information: + // https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/ephemeral-resources + TestEphemeralAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){ + "stackit": providerserver.NewProtocol6WithError(stackit.New("test-version")()), + "echo": echoprovider.NewProviderServer(), + } + // E2ETestsEnabled checks if end-to-end tests should be run. // It is enabled when the TF_ACC environment variable is set to "1". E2ETestsEnabled = os.Getenv("TF_ACC") == "1" diff --git a/stackit/provider.go b/stackit/provider.go index 3a2795ad3..2d950bc78 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" @@ -18,6 +19,7 @@ import ( "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/access_token" roleAssignements "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/authorization/roleassignments" cdnCustomDomain "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/customdomain" cdn "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/distribution" @@ -97,7 +99,8 @@ import ( // Ensure the implementation satisfies the expected interfaces var ( - _ provider.Provider = &Provider{} + _ provider.Provider = &Provider{} + _ provider.ProviderWithEphemeralResources = &Provider{} ) // Provider is the provider implementation. @@ -419,7 +422,6 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, setStringField(providerConfig.Token, func(v string) { sdkConfig.Token = v }) setStringField(providerConfig.TokenCustomEndpoint, func(v string) { sdkConfig.TokenCustomUrl = v }) - // Provider Data Configuration setStringField(providerConfig.DefaultRegion, func(v string) { providerData.DefaultRegion = v }) setStringField(providerConfig.Region, func(v string) { providerData.Region = v }) // nolint:staticcheck // preliminary handling of deprecated attribute setBoolField(providerConfig.EnableBetaResources, func(v bool) { providerData.EnableBetaResources = v }) @@ -472,6 +474,16 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, resp.DataSourceData = providerData resp.ResourceData = providerData + // Copy service account, private key credentials and custom-token endpoint to support ephemeral access token generation + var ephemeralProviderData core.EphemeralProviderData + ephemeralProviderData.ProviderData = providerData + setStringField(providerConfig.ServiceAccountKey, func(v string) { ephemeralProviderData.ServiceAccountKey = v }) + setStringField(providerConfig.ServiceAccountKeyPath, func(v string) { ephemeralProviderData.ServiceAccountKeyPath = v }) + setStringField(providerConfig.PrivateKey, func(v string) { ephemeralProviderData.PrivateKey = v }) + setStringField(providerConfig.PrivateKeyPath, func(v string) { ephemeralProviderData.PrivateKeyPath = v }) + setStringField(providerConfig.TokenCustomEndpoint, func(v string) { ephemeralProviderData.TokenCustomEndpoint = v }) + resp.EphemeralResourceData = ephemeralProviderData + providerData.Version = p.version } @@ -622,3 +634,10 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { return resources } + +// EphemeralResources defines the ephemeral resources implemented in the provider. +func (p *Provider) EphemeralResources(_ context.Context) []func() ephemeral.EphemeralResource { + return []func() ephemeral.EphemeralResource{ + access_token.NewAccessTokenEphemeralResource, + } +}