From 0ed44bd002546629d945a9ea616e53698e9cc8fe Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Fri, 21 Nov 2025 09:32:32 +0100 Subject: [PATCH 01/11] feat(access-token): add ephemeral access-token resource Signed-off-by: Mauritz Uphoff --- docs/ephemeral-resources/access_token.md | 39 ++++ .../ephemeral-resource.tf | 16 ++ stackit/internal/core/core.go | 6 + .../access_token/ephemeral_access_token.go | 200 ++++++++++++++++++ stackit/provider.go | 21 +- 5 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 docs/ephemeral-resources/access_token.md create mode 100644 examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf create mode 100644 stackit/internal/services/access_token/ephemeral_access_token.go diff --git a/docs/ephemeral-resources/access_token.md b/docs/ephemeral-resources/access_token.md new file mode 100644 index 000000000..624f1631f --- /dev/null +++ b/docs/ephemeral-resources/access_token.md @@ -0,0 +1,39 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_access_token Ephemeral Resource - stackit" +subcategory: "" +description: |- + STACKIT Access Token ephemeral resource schema. +--- + +# stackit_access_token (Ephemeral Resource) + +STACKIT Access Token ephemeral resource schema. + +## Example Usage + +```terraform +ephemeral "stackit_access_token" "example" {} + +// https://registry.terraform.io/providers/Mastercard/restapi/latest/docs +provider "restapi" { + alias = "stackit_iaas" + uri = "https://iaas.api.eu01.stackit.cloud" + write_returns_object = true + + headers = { + "Authorization" = "Bearer ${ephemeral.stackit_access_token.example.access_token}" + } + + create_method = "GET" + update_method = "GET" + destroy_method = "GET" +} +``` + + +## 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..7b80ef02e --- /dev/null +++ b/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf @@ -0,0 +1,16 @@ +ephemeral "stackit_access_token" "example" {} + +// https://registry.terraform.io/providers/Mastercard/restapi/latest/docs +provider "restapi" { + alias = "stackit_iaas" + uri = "https://iaas.api.eu01.stackit.cloud" + write_returns_object = true + + headers = { + "Authorization" = "Bearer ${ephemeral.stackit_access_token.example.access_token}" + } + + create_method = "GET" + update_method = "GET" + destroy_method = "GET" +} \ No newline at end of file diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index 733274074..1762a181e 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -29,6 +29,12 @@ const ( type ProviderData struct { RoundTripper http.RoundTripper ServiceAccountEmail string // Deprecated: ServiceAccountEmail is not required and will be removed after 12th June 2025. + + PrivateKey string + PrivateKeyPath string + ServiceAccountKey string + ServiceAccountKeyPath string + // Deprecated: Use DefaultRegion instead Region string DefaultRegion string diff --git a/stackit/internal/services/access_token/ephemeral_access_token.go b/stackit/internal/services/access_token/ephemeral_access_token.go new file mode 100644 index 000000000..003b5f603 --- /dev/null +++ b/stackit/internal/services/access_token/ephemeral_access_token.go @@ -0,0 +1,200 @@ +package access_token + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/clients" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" +) + +// #nosec G101 tokenUrl is a public endpoint, not a hardcoded credential +const tokenUrl = "https://service-account.api.stackit.cloud/token" + +var ( + _ ephemeral.EphemeralResource = &accessTokenEphemeralResource{} + _ ephemeral.EphemeralResourceWithConfigure = &accessTokenEphemeralResource{} +) + +func NewAccessTokenEphemeralResource() ephemeral.EphemeralResource { + return &accessTokenEphemeralResource{} +} + +type accessTokenEphemeralResource struct { + serviceAccountKeyPath string + serviceAccountKey string + privateKeyPath string + privateKey string +} + +func (e *accessTokenEphemeralResource) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + e.serviceAccountKey = providerData.ServiceAccountKey + e.serviceAccountKeyPath = providerData.ServiceAccountKeyPath + e.privateKey = providerData.PrivateKey + e.privateKeyPath = providerData.PrivateKeyPath +} + +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) { + resp.Schema = schema.Schema{ + Description: "STACKIT Access Token ephemeral resource schema.", + 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 + } + + serviceAccountKey, diags := loadServiceAccountKey(ctx, e.serviceAccountKey, e.serviceAccountKeyPath) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + privateKey, diags := resolvePrivateKey(ctx, e.privateKey, e.privateKeyPath, serviceAccountKey) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, diags := initKeyFlowClient(ctx, serviceAccountKey, privateKey) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + accessToken, err := client.GetAccessToken() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Access token generation failed", fmt.Sprintf("Error generating access token: %v", err)) + return + } + + ctx = tflog.SetField(ctx, "access_token", accessToken) + model.AccessToken = types.StringValue(accessToken) + resp.Diagnostics.Append(resp.Result.Set(ctx, model)...) +} + +// loadServiceAccountKey loads the service account key based on env vars, or fallback to provider config. +func loadServiceAccountKey(ctx context.Context, cfgValue, cfgPath string) (*clients.ServiceAccountKeyResponse, diag.Diagnostics) { + var diags diag.Diagnostics + + env := os.Getenv("STACKIT_SERVICE_ACCOUNT_KEY") + envPath := os.Getenv("STACKIT_SERVICE_ACCOUNT_KEY_PATH") + + var data []byte + switch { + case env != "": + data = []byte(env) + case envPath != "": + b, err := os.ReadFile(envPath) + if err != nil { + core.LogAndAddError(ctx, &diags, "Failed to read service account key file (env path)", fmt.Sprintf("Error reading key file: %v", err)) + return nil, diags + } + data = b + case cfgValue != "": + data = []byte(cfgValue) + case cfgPath != "": + b, err := os.ReadFile(cfgPath) + if err != nil { + core.LogAndAddError(ctx, &diags, "Failed to read service account key file (provider path)", fmt.Sprintf("Error reading key file: %v", err)) + return nil, diags + } + data = b + default: + core.LogAndAddError(ctx, &diags, "Missing service account key", "Neither STACKIT_SERVICE_ACCOUNT_KEY, STACKIT_SERVICE_ACCOUNT_KEY_PATH, provider value, nor path were provided.") + return nil, diags + } + + var key clients.ServiceAccountKeyResponse + if err := json.Unmarshal(data, &key); err != nil { + core.LogAndAddError(ctx, &diags, "Failed to parse service account key", fmt.Sprintf("Unmarshal error: %v", err)) + return nil, diags + } + + return &key, diags +} + +// resolvePrivateKey determines the private key value using env, conf, fallbacks. +func resolvePrivateKey(ctx context.Context, cfgValue, cfgPath string, key *clients.ServiceAccountKeyResponse) (string, diag.Diagnostics) { + var diags diag.Diagnostics + + env := os.Getenv("STACKIT_PRIVATE_KEY") + envPath := os.Getenv("STACKIT_PRIVATE_KEY_PATH") + + switch { + case env != "": + return env, diags + case envPath != "": + content, err := os.ReadFile(envPath) + if err != nil { + core.LogAndAddError(ctx, &diags, "Failed to read private key file (env path)", fmt.Sprintf("Error: %v", err)) + return "", diags + } + return string(content), diags + case cfgValue != "": + return cfgValue, diags + case cfgPath != "": + content, err := os.ReadFile(cfgPath) + if err != nil { + core.LogAndAddError(ctx, &diags, "Failed to read private key file (provider path)", fmt.Sprintf("Error: %v", err)) + return "", diags + } + return string(content), diags + case key.Credentials != nil && key.Credentials.PrivateKey != nil: + return *key.Credentials.PrivateKey, diags + default: + core.LogAndAddError(ctx, &diags, "Missing private key", "No private key set via env, provider, or service account credentials.") + return "", diags + } +} + +// initKeyFlowClient configures and initializes a new KeyFlow client using the key and private key. +func initKeyFlowClient(ctx context.Context, key *clients.ServiceAccountKeyResponse, privateKey string) (*clients.KeyFlow, diag.Diagnostics) { + var diags diag.Diagnostics + + client := &clients.KeyFlow{} + cfg := &clients.KeyFlowConfig{ + ServiceAccountKey: key, + PrivateKey: privateKey, + TokenUrl: tokenUrl, + } + + if err := client.Init(cfg); err != nil { + core.LogAndAddError(ctx, &diags, "Failed to initialize KeyFlow", fmt.Sprintf("KeyFlow client init error: %v", err)) + return nil, diags + } + + return client, diags +} diff --git a/stackit/provider.go b/stackit/provider.go index 3a2795ad3..bec053e8d 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,14 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, resp.DataSourceData = providerData resp.ResourceData = providerData + // Copy service account and private key credentials to support ephemeral access token generation + ephemeralProviderData := 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 }) + resp.EphemeralResourceData = ephemeralProviderData + providerData.Version = p.version } @@ -622,3 +632,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, + } +} From 6926dedb008c556ebe6962cd7311be8b5dbe1b3c Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Mon, 24 Nov 2025 09:12:48 +0100 Subject: [PATCH 02/11] review changes Signed-off-by: Mauritz Uphoff --- stackit/internal/conversion/conversion.go | 15 ++ stackit/internal/core/core.go | 11 +- .../access_token/access_token_acc_test.go | 1 + .../access_token/ephemeral_access_token.go | 200 ------------------ .../access_token/ephemeral_resource.go | 111 ++++++++++ .../access_token/ephemeral_resource_test.go | 1 + stackit/provider.go | 5 +- 7 files changed, 139 insertions(+), 205 deletions(-) create mode 100644 stackit/internal/services/access_token/access_token_acc_test.go delete mode 100644 stackit/internal/services/access_token/ephemeral_access_token.go create mode 100644 stackit/internal/services/access_token/ephemeral_resource.go create mode 100644 stackit/internal/services/access_token/ephemeral_resource_test.go diff --git a/stackit/internal/conversion/conversion.go b/stackit/internal/conversion/conversion.go index 312535f01..9c810fcc6 100644 --- a/stackit/internal/conversion/conversion.go +++ b/stackit/internal/conversion/conversion.go @@ -183,3 +183,18 @@ func ParseProviderData(ctx context.Context, providerData any, diags *diag.Diagno } return stackitProviderData, true } + +// TODO: write tests +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", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", providerData)) + return core.EphemeralProviderData{}, false + } + return stackitProviderData, true +} diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index 1762a181e..85990a448 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -26,14 +26,19 @@ const ( DatasourceRegionFallbackDocstring = "Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on datasource level." ) -type ProviderData struct { - RoundTripper http.RoundTripper - ServiceAccountEmail string // Deprecated: ServiceAccountEmail is not required and will be removed after 12th June 2025. +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. // Deprecated: Use DefaultRegion instead Region string 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..27a2afcf2 --- /dev/null +++ b/stackit/internal/services/access_token/access_token_acc_test.go @@ -0,0 +1 @@ +package access_token diff --git a/stackit/internal/services/access_token/ephemeral_access_token.go b/stackit/internal/services/access_token/ephemeral_access_token.go deleted file mode 100644 index 003b5f603..000000000 --- a/stackit/internal/services/access_token/ephemeral_access_token.go +++ /dev/null @@ -1,200 +0,0 @@ -package access_token - -import ( - "context" - "encoding/json" - "fmt" - "os" - - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/ephemeral" - "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-log/tflog" - "github.com/stackitcloud/stackit-sdk-go/core/clients" - "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" - "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" -) - -// #nosec G101 tokenUrl is a public endpoint, not a hardcoded credential -const tokenUrl = "https://service-account.api.stackit.cloud/token" - -var ( - _ ephemeral.EphemeralResource = &accessTokenEphemeralResource{} - _ ephemeral.EphemeralResourceWithConfigure = &accessTokenEphemeralResource{} -) - -func NewAccessTokenEphemeralResource() ephemeral.EphemeralResource { - return &accessTokenEphemeralResource{} -} - -type accessTokenEphemeralResource struct { - serviceAccountKeyPath string - serviceAccountKey string - privateKeyPath string - privateKey string -} - -func (e *accessTokenEphemeralResource) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { - providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) - if !ok { - return - } - - e.serviceAccountKey = providerData.ServiceAccountKey - e.serviceAccountKeyPath = providerData.ServiceAccountKeyPath - e.privateKey = providerData.PrivateKey - e.privateKeyPath = providerData.PrivateKeyPath -} - -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) { - resp.Schema = schema.Schema{ - Description: "STACKIT Access Token ephemeral resource schema.", - 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 - } - - serviceAccountKey, diags := loadServiceAccountKey(ctx, e.serviceAccountKey, e.serviceAccountKeyPath) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - privateKey, diags := resolvePrivateKey(ctx, e.privateKey, e.privateKeyPath, serviceAccountKey) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - client, diags := initKeyFlowClient(ctx, serviceAccountKey, privateKey) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - accessToken, err := client.GetAccessToken() - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Access token generation failed", fmt.Sprintf("Error generating access token: %v", err)) - return - } - - ctx = tflog.SetField(ctx, "access_token", accessToken) - model.AccessToken = types.StringValue(accessToken) - resp.Diagnostics.Append(resp.Result.Set(ctx, model)...) -} - -// loadServiceAccountKey loads the service account key based on env vars, or fallback to provider config. -func loadServiceAccountKey(ctx context.Context, cfgValue, cfgPath string) (*clients.ServiceAccountKeyResponse, diag.Diagnostics) { - var diags diag.Diagnostics - - env := os.Getenv("STACKIT_SERVICE_ACCOUNT_KEY") - envPath := os.Getenv("STACKIT_SERVICE_ACCOUNT_KEY_PATH") - - var data []byte - switch { - case env != "": - data = []byte(env) - case envPath != "": - b, err := os.ReadFile(envPath) - if err != nil { - core.LogAndAddError(ctx, &diags, "Failed to read service account key file (env path)", fmt.Sprintf("Error reading key file: %v", err)) - return nil, diags - } - data = b - case cfgValue != "": - data = []byte(cfgValue) - case cfgPath != "": - b, err := os.ReadFile(cfgPath) - if err != nil { - core.LogAndAddError(ctx, &diags, "Failed to read service account key file (provider path)", fmt.Sprintf("Error reading key file: %v", err)) - return nil, diags - } - data = b - default: - core.LogAndAddError(ctx, &diags, "Missing service account key", "Neither STACKIT_SERVICE_ACCOUNT_KEY, STACKIT_SERVICE_ACCOUNT_KEY_PATH, provider value, nor path were provided.") - return nil, diags - } - - var key clients.ServiceAccountKeyResponse - if err := json.Unmarshal(data, &key); err != nil { - core.LogAndAddError(ctx, &diags, "Failed to parse service account key", fmt.Sprintf("Unmarshal error: %v", err)) - return nil, diags - } - - return &key, diags -} - -// resolvePrivateKey determines the private key value using env, conf, fallbacks. -func resolvePrivateKey(ctx context.Context, cfgValue, cfgPath string, key *clients.ServiceAccountKeyResponse) (string, diag.Diagnostics) { - var diags diag.Diagnostics - - env := os.Getenv("STACKIT_PRIVATE_KEY") - envPath := os.Getenv("STACKIT_PRIVATE_KEY_PATH") - - switch { - case env != "": - return env, diags - case envPath != "": - content, err := os.ReadFile(envPath) - if err != nil { - core.LogAndAddError(ctx, &diags, "Failed to read private key file (env path)", fmt.Sprintf("Error: %v", err)) - return "", diags - } - return string(content), diags - case cfgValue != "": - return cfgValue, diags - case cfgPath != "": - content, err := os.ReadFile(cfgPath) - if err != nil { - core.LogAndAddError(ctx, &diags, "Failed to read private key file (provider path)", fmt.Sprintf("Error: %v", err)) - return "", diags - } - return string(content), diags - case key.Credentials != nil && key.Credentials.PrivateKey != nil: - return *key.Credentials.PrivateKey, diags - default: - core.LogAndAddError(ctx, &diags, "Missing private key", "No private key set via env, provider, or service account credentials.") - return "", diags - } -} - -// initKeyFlowClient configures and initializes a new KeyFlow client using the key and private key. -func initKeyFlowClient(ctx context.Context, key *clients.ServiceAccountKeyResponse, privateKey string) (*clients.KeyFlow, diag.Diagnostics) { - var diags diag.Diagnostics - - client := &clients.KeyFlow{} - cfg := &clients.KeyFlowConfig{ - ServiceAccountKey: key, - PrivateKey: privateKey, - TokenUrl: tokenUrl, - } - - if err := client.Init(cfg); err != nil { - core.LogAndAddError(ctx, &diags, "Failed to initialize KeyFlow", fmt.Sprintf("KeyFlow client init error: %v", err)) - return nil, diags - } - - return client, diags -} 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..75ad9b082 --- /dev/null +++ b/stackit/internal/services/access_token/ephemeral_resource.go @@ -0,0 +1,111 @@ +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" +) + +var ( + _ ephemeral.EphemeralResource = &accessTokenEphemeralResource{} + _ ephemeral.EphemeralResourceWithConfigure = &accessTokenEphemeralResource{} +) + +func NewAccessTokenEphemeralResource() ephemeral.EphemeralResource { + return &accessTokenEphemeralResource{} +} + +type accessTokenEphemeralResource struct { + serviceAccountKeyPath string + serviceAccountKey string + privateKeyPath string + privateKey string + tokenCustomEndpoint string +} + +func (e *accessTokenEphemeralResource) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { + providerData, ok := conversion.ParseEphemeralProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + e.serviceAccountKey = providerData.ServiceAccountKey + e.serviceAccountKeyPath = providerData.ServiceAccountKeyPath + e.privateKey = providerData.PrivateKey + e.privateKeyPath = providerData.PrivateKeyPath + e.tokenCustomEndpoint = providerData.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) { + resp.Schema = schema.Schema{ + Description: "STACKIT Access Token ephemeral resource schema.", + 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 + } + + cfg := config.Configuration{ + ServiceAccountKey: e.serviceAccountKey, + ServiceAccountKeyPath: e.serviceAccountKeyPath, + PrivateKeyPath: e.privateKeyPath, + PrivateKey: e.privateKey, + TokenCustomUrl: e.tokenCustomEndpoint, + } + + rt, err := auth.KeyAuth(&cfg) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Access token generation failed", fmt.Sprintf("Failed to initialize authentication: %v", err)) + return + } + + // Type assert to access token functionality + client, ok := rt.(*clients.KeyFlow) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Access token generation failed", "Internal error: expected *clients.KeyFlow, but received a different implementation of http.RoundTripper") + return + } + + // Retrieve the access token + accessToken, err := client.GetAccessToken() + if err != nil { + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Access token retrieval failed", + fmt.Sprintf("Error obtaining access token: %v", err), + ) + return + } + + model.AccessToken = types.StringValue(accessToken) + resp.Diagnostics.Append(resp.Result.Set(ctx, model)...) +} 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..27a2afcf2 --- /dev/null +++ b/stackit/internal/services/access_token/ephemeral_resource_test.go @@ -0,0 +1 @@ +package access_token diff --git a/stackit/provider.go b/stackit/provider.go index bec053e8d..b71e45cea 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -474,12 +474,13 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, resp.DataSourceData = providerData resp.ResourceData = providerData - // Copy service account and private key credentials to support ephemeral access token generation - ephemeralProviderData := providerData + // Copy service account, private key credentials and custom-token endpoint to support ephemeral access token generation + var ephemeralProviderData core.EphemeralProviderData 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 From a5bc29ef59c4203ed6b84841e4cdac2ba11b765b Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Mon, 24 Nov 2025 10:24:23 +0100 Subject: [PATCH 03/11] review changes 2 Signed-off-by: Mauritz Uphoff --- docs/ephemeral-resources/access_token.md | 34 +++++-- .../ephemeral-resource.tf | 32 +++++-- stackit/internal/conversion/conversion.go | 1 - .../internal/conversion/conversion_test.go | 88 +++++++++++++++++++ stackit/internal/core/core.go | 3 - .../access_token/access_token_acc_test.go | 51 ++++++++++- .../access_token/ephemeral_resource.go | 41 ++++----- .../access_token/ephemeral_resource_test.go | 1 - .../testdata/ephemeral_resource.tf | 14 +++ stackit/internal/testutil/testutil.go | 13 +++ 10 files changed, 231 insertions(+), 47 deletions(-) delete mode 100644 stackit/internal/services/access_token/ephemeral_resource_test.go create mode 100644 stackit/internal/services/access_token/testdata/ephemeral_resource.tf diff --git a/docs/ephemeral-resources/access_token.md b/docs/ephemeral-resources/access_token.md index 624f1631f..42b58674b 100644 --- a/docs/ephemeral-resources/access_token.md +++ b/docs/ephemeral-resources/access_token.md @@ -3,12 +3,12 @@ page_title: "stackit_access_token Ephemeral Resource - stackit" subcategory: "" description: |- - STACKIT Access Token ephemeral resource schema. + 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. Token generation logic prioritizes environment variables first, followed by provider configuration. Access tokens generated from service account keys expire after 60 minutes. --- # stackit_access_token (Ephemeral Resource) -STACKIT Access Token ephemeral resource schema. +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. Token generation logic prioritizes environment variables first, followed by provider configuration. Access tokens generated from service account keys expire after 60 minutes. ## Example Usage @@ -17,17 +17,35 @@ ephemeral "stackit_access_token" "example" {} // https://registry.terraform.io/providers/Mastercard/restapi/latest/docs provider "restapi" { - alias = "stackit_iaas" - uri = "https://iaas.api.eu01.stackit.cloud" + uri = "https://iaas.api.eu01.stackit.cloud/" write_returns_object = true headers = { - "Authorization" = "Bearer ${ephemeral.stackit_access_token.example.access_token}" + Authorization = "Bearer ${ephemeral.stackit_access_token.example.access_token}" + Content-Type = "application/json" } - create_method = "GET" - update_method = "GET" - destroy_method = "GET" + create_method = "POST" + update_method = "PUT" + destroy_method = "DELETE" +} + +resource "restapi_object" "iaas_keypair" { + path = "/v2/keypairs" + + data = jsonencode({ + labels = { + key = "testvalue" + } + name = "test-keypair-123" + publicKey = file(chomp("~/.ssh/id_rsa.pub")) + }) + + id_attribute = "name" + read_method = "GET" + create_method = "POST" + update_method = "PATCH" + destroy_method = "DELETE" } ``` diff --git a/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf b/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf index 7b80ef02e..10bf202e1 100644 --- a/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf +++ b/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf @@ -2,15 +2,33 @@ ephemeral "stackit_access_token" "example" {} // https://registry.terraform.io/providers/Mastercard/restapi/latest/docs provider "restapi" { - alias = "stackit_iaas" - uri = "https://iaas.api.eu01.stackit.cloud" + uri = "https://iaas.api.eu01.stackit.cloud/" write_returns_object = true headers = { - "Authorization" = "Bearer ${ephemeral.stackit_access_token.example.access_token}" + Authorization = "Bearer ${ephemeral.stackit_access_token.example.access_token}" + Content-Type = "application/json" } - create_method = "GET" - update_method = "GET" - destroy_method = "GET" -} \ No newline at end of file + create_method = "POST" + update_method = "PUT" + destroy_method = "DELETE" +} + +resource "restapi_object" "iaas_keypair" { + path = "/v2/keypairs" + + data = jsonencode({ + labels = { + key = "testvalue" + } + name = "test-keypair-123" + publicKey = file(chomp("~/.ssh/id_rsa.pub")) + }) + + id_attribute = "name" + 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 9c810fcc6..1017e4334 100644 --- a/stackit/internal/conversion/conversion.go +++ b/stackit/internal/conversion/conversion.go @@ -184,7 +184,6 @@ func ParseProviderData(ctx context.Context, providerData any, diags *diag.Diagno return stackitProviderData, true } -// TODO: write tests 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 { 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 85990a448..50820eef6 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -27,8 +27,6 @@ const ( ) type EphemeralProviderData struct { - ProviderData - PrivateKey string PrivateKeyPath string ServiceAccountKey string @@ -39,7 +37,6 @@ type EphemeralProviderData struct { type ProviderData struct { RoundTripper http.RoundTripper ServiceAccountEmail string // Deprecated: ServiceAccountEmail is not required and will be removed after 12th June 2025. - // Deprecated: Use DefaultRegion instead Region string DefaultRegion string diff --git a/stackit/internal/services/access_token/access_token_acc_test.go b/stackit/internal/services/access_token/access_token_acc_test.go index 27a2afcf2..d544fe39d 100644 --- a/stackit/internal/services/access_token/access_token_acc_test.go +++ b/stackit/internal/services/access_token/access_token_acc_test.go @@ -1 +1,50 @@ -package access_token +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 index 75ad9b082..d8b0726fa 100644 --- a/stackit/internal/services/access_token/ephemeral_resource.go +++ b/stackit/internal/services/access_token/ephemeral_resource.go @@ -24,11 +24,7 @@ func NewAccessTokenEphemeralResource() ephemeral.EphemeralResource { } type accessTokenEphemeralResource struct { - serviceAccountKeyPath string - serviceAccountKey string - privateKeyPath string - privateKey string - tokenCustomEndpoint string + keyAuthConfig config.Configuration } func (e *accessTokenEphemeralResource) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { @@ -37,11 +33,13 @@ func (e *accessTokenEphemeralResource) Configure(ctx context.Context, req epheme return } - e.serviceAccountKey = providerData.ServiceAccountKey - e.serviceAccountKeyPath = providerData.ServiceAccountKeyPath - e.privateKey = providerData.PrivateKey - e.privateKeyPath = providerData.PrivateKeyPath - e.tokenCustomEndpoint = providerData.TokenCustomEndpoint + e.keyAuthConfig = config.Configuration{ + ServiceAccountKey: providerData.ServiceAccountKey, + ServiceAccountKeyPath: providerData.ServiceAccountKeyPath, + PrivateKeyPath: providerData.PrivateKey, + PrivateKey: providerData.PrivateKeyPath, + TokenCustomUrl: providerData.TokenCustomEndpoint, + } } type ephemeralTokenModel struct { @@ -54,7 +52,11 @@ func (e *accessTokenEphemeralResource) Metadata(_ context.Context, req ephemeral func (e *accessTokenEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { resp.Schema = schema.Schema{ - Description: "STACKIT Access Token ephemeral resource schema.", + 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. " + + "Token generation logic prioritizes environment variables first, followed by provider configuration. " + + "Access tokens generated from service account keys expire after 60 minutes.", Attributes: map[string]schema.Attribute{ "access_token": schema.StringAttribute{ Description: "JWT access token for STACKIT API authentication.", @@ -73,15 +75,7 @@ func (e *accessTokenEphemeralResource) Open(ctx context.Context, req ephemeral.O return } - cfg := config.Configuration{ - ServiceAccountKey: e.serviceAccountKey, - ServiceAccountKeyPath: e.serviceAccountKeyPath, - PrivateKeyPath: e.privateKeyPath, - PrivateKey: e.privateKey, - TokenCustomUrl: e.tokenCustomEndpoint, - } - - rt, err := auth.KeyAuth(&cfg) + rt, err := auth.KeyAuth(&e.keyAuthConfig) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Access token generation failed", fmt.Sprintf("Failed to initialize authentication: %v", err)) return @@ -97,12 +91,7 @@ func (e *accessTokenEphemeralResource) Open(ctx context.Context, req ephemeral.O // Retrieve the access token accessToken, err := client.GetAccessToken() if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Access token retrieval failed", - fmt.Sprintf("Error obtaining access token: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Access token retrieval failed", fmt.Sprintf("Error obtaining access token: %v", err)) return } diff --git a/stackit/internal/services/access_token/ephemeral_resource_test.go b/stackit/internal/services/access_token/ephemeral_resource_test.go deleted file mode 100644 index 27a2afcf2..000000000 --- a/stackit/internal/services/access_token/ephemeral_resource_test.go +++ /dev/null @@ -1 +0,0 @@ -package access_token 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..f2f2ad18d --- /dev/null +++ b/stackit/internal/services/access_token/testdata/ephemeral_resource.tf @@ -0,0 +1,14 @@ +variable "default_region" {} + +provider "stackit" { + default_region = var.default_region +} + +ephemeral "stackit_access_token" "example" {} + +provider "echo" { + data = ephemeral.stackit_access_token.example +} + +resource "echo" "example" { +} 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" From f177b1fe4fe21a5f0c07d39106349d8d88f27cb4 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Wed, 26 Nov 2025 17:25:27 +0100 Subject: [PATCH 04/11] review changes 3 Signed-off-by: Mauritz Uphoff --- docs/ephemeral-resources/access_token.md | 65 ++++- .../ephemeral-resource.tf | 62 ++++- stackit/internal/core/core.go | 6 +- .../access_token/ephemeral_resource.go | 59 ++-- .../access_token/ephemeral_resource_test.go | 253 ++++++++++++++++++ .../testdata/ephemeral_resource.tf | 3 +- .../testdata/service_account.json | 16 ++ stackit/provider.go | 1 + 8 files changed, 414 insertions(+), 51 deletions(-) create mode 100644 stackit/internal/services/access_token/ephemeral_resource_test.go create mode 100644 stackit/internal/services/access_token/testdata/service_account.json diff --git a/docs/ephemeral-resources/access_token.md b/docs/ephemeral-resources/access_token.md index 42b58674b..e6a195eaf 100644 --- a/docs/ephemeral-resources/access_token.md +++ b/docs/ephemeral-resources/access_token.md @@ -4,20 +4,40 @@ 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. Token generation logic prioritizes environment variables first, followed by provider configuration. Access tokens generated from service account keys expire after 60 minutes. + ~> 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. Token generation logic prioritizes environment variables first, followed by provider configuration. Access tokens generated from service account keys expire after 60 minutes. +~> 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" {} -// https://registry.terraform.io/providers/Mastercard/restapi/latest/docs +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/Mastercard/restapi/latest provider "restapi" { - uri = "https://iaas.api.eu01.stackit.cloud/" + uri = local.stackit_api_base_url write_returns_object = true headers = { @@ -26,27 +46,44 @@ provider "restapi" { } create_method = "POST" - update_method = "PUT" + update_method = "PATCH" destroy_method = "DELETE" } -resource "restapi_object" "iaas_keypair" { - path = "/v2/keypairs" +resource "restapi_object" "public_ip_restapi" { + path = local.public_ip_path + data = jsonencode(local.public_ip_payload) - data = jsonencode({ - labels = { - key = "testvalue" - } - name = "test-keypair-123" - publicKey = file(chomp("~/.ssh/id_rsa.pub")) - }) - - id_attribute = "name" + id_attribute = "id" read_method = "GET" create_method = "POST" update_method = "PATCH" destroy_method = "DELETE" } + +# 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 + data = jsonencode(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" +} ``` diff --git a/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf b/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf index 10bf202e1..2695061b5 100644 --- a/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf +++ b/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf @@ -1,8 +1,25 @@ +provider "stackit" { + default_region = "eu01" + service_account_key_path = "/path/to/sa_key.json" + enable_beta_resources = true +} + ephemeral "stackit_access_token" "example" {} -// https://registry.terraform.io/providers/Mastercard/restapi/latest/docs +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/Mastercard/restapi/latest provider "restapi" { - uri = "https://iaas.api.eu01.stackit.cloud/" + uri = local.stackit_api_base_url write_returns_object = true headers = { @@ -11,24 +28,41 @@ provider "restapi" { } create_method = "POST" - update_method = "PUT" + update_method = "PATCH" destroy_method = "DELETE" } -resource "restapi_object" "iaas_keypair" { - path = "/v2/keypairs" +resource "restapi_object" "public_ip_restapi" { + path = local.public_ip_path + data = jsonencode(local.public_ip_payload) - data = jsonencode({ - labels = { - key = "testvalue" - } - name = "test-keypair-123" - publicKey = file(chomp("~/.ssh/id_rsa.pub")) - }) - - id_attribute = "name" + id_attribute = "id" read_method = "GET" create_method = "POST" update_method = "PATCH" destroy_method = "DELETE" } + +# 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 + data = jsonencode(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" +} \ No newline at end of file diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index 50820eef6..88bdc6a41 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 = "," @@ -32,6 +33,7 @@ type EphemeralProviderData struct { ServiceAccountKey string ServiceAccountKeyPath string TokenCustomEndpoint string + EnableBetaResources bool } type ProviderData struct { diff --git a/stackit/internal/services/access_token/ephemeral_resource.go b/stackit/internal/services/access_token/ephemeral_resource.go index d8b0726fa..f61be5f8c 100644 --- a/stackit/internal/services/access_token/ephemeral_resource.go +++ b/stackit/internal/services/access_token/ephemeral_resource.go @@ -12,6 +12,7 @@ import ( "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 ( @@ -28,17 +29,27 @@ type accessTokenEphemeralResource struct { } func (e *accessTokenEphemeralResource) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { - providerData, ok := conversion.ParseEphemeralProviderData(ctx, req.ProviderData, &resp.Diagnostics) + ephemeralProviderData, ok := conversion.ParseEphemeralProviderData(ctx, req.ProviderData, &resp.Diagnostics) if !ok { return } + features.CheckBetaResourcesEnabled( + ctx, + &core.ProviderData{EnableBetaResources: ephemeralProviderData.EnableBetaResources}, + &resp.Diagnostics, + "stackit_access_token", "ephemeral_resource", + ) + if resp.Diagnostics.HasError() { + return + } + e.keyAuthConfig = config.Configuration{ - ServiceAccountKey: providerData.ServiceAccountKey, - ServiceAccountKeyPath: providerData.ServiceAccountKeyPath, - PrivateKeyPath: providerData.PrivateKey, - PrivateKey: providerData.PrivateKeyPath, - TokenCustomUrl: providerData.TokenCustomEndpoint, + ServiceAccountKey: ephemeralProviderData.ServiceAccountKey, + ServiceAccountKeyPath: ephemeralProviderData.ServiceAccountKeyPath, + PrivateKeyPath: ephemeralProviderData.PrivateKey, + PrivateKey: ephemeralProviderData.PrivateKeyPath, + TokenCustomUrl: ephemeralProviderData.TokenCustomEndpoint, } } @@ -52,11 +63,11 @@ func (e *accessTokenEphemeralResource) Metadata(_ context.Context, req ephemeral func (e *accessTokenEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { resp.Schema = schema.Schema{ - 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. " + - "Token generation logic prioritizes environment variables first, followed by provider configuration. " + - "Access tokens generated from service account keys expire after 60 minutes.", + Description: features.AddBetaDescription("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. "+ + "Token generation logic prioritizes environment variables first, followed by provider configuration. "+ + "Access tokens generated from service account keys expire after 60 minutes.", core.EphemeralResource), Attributes: map[string]schema.Attribute{ "access_token": schema.StringAttribute{ Description: "JWT access token for STACKIT API authentication.", @@ -75,26 +86,34 @@ func (e *accessTokenEphemeralResource) Open(ctx context.Context, req ephemeral.O return } - rt, err := auth.KeyAuth(&e.keyAuthConfig) + accessToken, err := getAccessToken(&e.keyAuthConfig) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Access token generation failed", fmt.Sprintf("Failed to initialize authentication: %v", err)) + 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", err) + } + // Type assert to access token functionality - client, ok := rt.(*clients.KeyFlow) + client, ok := roundTripper.(*clients.KeyFlow) if !ok { - core.LogAndAddError(ctx, &resp.Diagnostics, "Access token generation failed", "Internal error: expected *clients.KeyFlow, but received a different implementation of http.RoundTripper") - return + 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 { - core.LogAndAddError(ctx, &resp.Diagnostics, "Access token retrieval failed", fmt.Sprintf("Error obtaining access token: %v", err)) - return + return "", fmt.Errorf("error obtaining access token: %w", err) } - model.AccessToken = types.StringValue(accessToken) - resp.Diagnostics.Append(resp.Result.Set(ctx, model)...) + 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..c5624b156 --- /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 index f2f2ad18d..3d5731a30 100644 --- a/stackit/internal/services/access_token/testdata/ephemeral_resource.tf +++ b/stackit/internal/services/access_token/testdata/ephemeral_resource.tf @@ -1,7 +1,8 @@ variable "default_region" {} provider "stackit" { - default_region = var.default_region + default_region = var.default_region + enable_beta_resources = true } ephemeral "stackit_access_token" "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/provider.go b/stackit/provider.go index b71e45cea..f80838829 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -481,6 +481,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, 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 }) + setBoolField(providerConfig.EnableBetaResources, func(v bool) { ephemeralProviderData.EnableBetaResources = v }) resp.EphemeralResourceData = ephemeralProviderData providerData.Version = p.version From 1bab9f13362875b745cbe887528baee3d29638f3 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Fri, 28 Nov 2025 09:27:55 +0100 Subject: [PATCH 05/11] review changes 4 Signed-off-by: Mauritz Uphoff --- stackit/internal/core/core.go | 3 ++- .../internal/services/access_token/ephemeral_resource.go | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index 88bdc6a41..e3dd02e0f 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -28,12 +28,13 @@ const ( ) type EphemeralProviderData struct { + ProviderData + PrivateKey string PrivateKeyPath string ServiceAccountKey string ServiceAccountKeyPath string TokenCustomEndpoint string - EnableBetaResources bool } type ProviderData struct { diff --git a/stackit/internal/services/access_token/ephemeral_resource.go b/stackit/internal/services/access_token/ephemeral_resource.go index f61be5f8c..1c36fa62a 100644 --- a/stackit/internal/services/access_token/ephemeral_resource.go +++ b/stackit/internal/services/access_token/ephemeral_resource.go @@ -100,7 +100,11 @@ func (e *accessTokenEphemeralResource) Open(ctx context.Context, req ephemeral.O func getAccessToken(keyAuthConfig *config.Configuration) (string, error) { roundTripper, err := auth.KeyAuth(keyAuthConfig) if err != nil { - return "", fmt.Errorf("failed to initialize authentication: %w", err) + 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 From 08bdae49f0a8196fbe1ae4dfc99c76942440d752 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Fri, 28 Nov 2025 09:47:00 +0100 Subject: [PATCH 06/11] review changes 5 (improve example) Signed-off-by: Mauritz Uphoff --- docs/ephemeral-resources/access_token.md | 48 +++++++++---------- .../ephemeral-resource.tf | 48 +++++++++---------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/docs/ephemeral-resources/access_token.md b/docs/ephemeral-resources/access_token.md index e6a195eaf..4a0e1603c 100644 --- a/docs/ephemeral-resources/access_token.md +++ b/docs/ephemeral-resources/access_token.md @@ -35,6 +35,30 @@ locals { } } +# 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 @@ -60,30 +84,6 @@ resource "restapi_object" "public_ip_restapi" { update_method = "PATCH" destroy_method = "DELETE" } - -# 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 - data = jsonencode(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" -} ``` diff --git a/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf b/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf index 2695061b5..62ff9f8e5 100644 --- a/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf +++ b/examples/ephemeral-resources/stackit_access_token/ephemeral-resource.tf @@ -17,6 +17,30 @@ locals { } } +# 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 @@ -42,27 +66,3 @@ resource "restapi_object" "public_ip_restapi" { update_method = "PATCH" destroy_method = "DELETE" } - -# 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 - data = jsonencode(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" -} \ No newline at end of file From 6394938a4681a1fd955551a768a76e986c6deacf Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Fri, 28 Nov 2025 10:12:18 +0100 Subject: [PATCH 07/11] review changes 5 (improve test description) Signed-off-by: Mauritz Uphoff --- .../access_token/ephemeral_resource_test.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/stackit/internal/services/access_token/ephemeral_resource_test.go b/stackit/internal/services/access_token/ephemeral_resource_test.go index c5624b156..5df2b91ce 100644 --- a/stackit/internal/services/access_token/ephemeral_resource_test.go +++ b/stackit/internal/services/access_token/ephemeral_resource_test.go @@ -88,7 +88,7 @@ func TestGetAccessToken(t *testing.T) { expected string }{ { - description: "should_return_token_when_service_account_key_passed_by_value", + description: "should return token when service account key passed by value", cfgFactory: func() *config.Configuration { return &config.Configuration{ ServiceAccountKey: testServiceAccountKey, @@ -100,7 +100,7 @@ func TestGetAccessToken(t *testing.T) { expected: "mock_access_token", }, { - description: "should_return_token_when_service_account_key_is_loaded_from_file_path", + 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", @@ -112,7 +112,7 @@ func TestGetAccessToken(t *testing.T) { expected: "mock_access_token", }, { - description: "should_fail_when_private_key_is_invalid", + description: "should fail when private key is invalid", cfgFactory: func() *config.Configuration { return &config.Configuration{ ServiceAccountKey: "invalid-json", @@ -124,7 +124,7 @@ func TestGetAccessToken(t *testing.T) { expected: "", }, { - description: "should_return_token_when_service_account_key_is_set_via_env", + description: "should return token when service account key is set via env", setupEnv: func() { _ = os.Setenv("STACKIT_SERVICE_ACCOUNT_KEY", testServiceAccountKey) }, @@ -141,7 +141,7 @@ func TestGetAccessToken(t *testing.T) { expected: "mock_access_token", }, { - description: "should_return_token_when_service_account_key_path_is_set_via_env", + 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") }, @@ -158,7 +158,7 @@ func TestGetAccessToken(t *testing.T) { expected: "mock_access_token", }, { - description: "should_return_token_when_private_key_is_set_via_env", + description: "should return token when private key is set via env", setupEnv: func() { _ = os.Setenv("STACKIT_PRIVATE_KEY", privateKey) }, @@ -175,7 +175,7 @@ func TestGetAccessToken(t *testing.T) { expected: "mock_access_token", }, { - description: "should_return_token_when_private_key_path_is_set_via_env", + description: "should return token when private key path is set via env", setupEnv: func() { // Write temp file and set env tmpFile := writeTempPEMFile(t, privateKey) @@ -194,7 +194,7 @@ func TestGetAccessToken(t *testing.T) { expected: "mock_access_token", }, { - description: "should_fail_when_no_service_account_key_or_private_key_is_set", + description: "should fail when no service account key or private key is set", cfgFactory: func() *config.Configuration { return &config.Configuration{ TokenCustomUrl: mockServer.URL, @@ -204,7 +204,7 @@ func TestGetAccessToken(t *testing.T) { expected: "", }, { - description: "should_fail_when_no_service_account_key_or_private_key_is_set_via_env", + 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") From 785862b92a14d6e92ee665e92b4e3764deeb5bf0 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Fri, 28 Nov 2025 16:13:08 +0100 Subject: [PATCH 08/11] review changes 5 (improve note docs) Signed-off-by: Mauritz Uphoff --- docs/ephemeral-resources/access_token.md | 7 +++++-- .../access_token/ephemeral_resource.go | 21 +++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/ephemeral-resources/access_token.md b/docs/ephemeral-resources/access_token.md index 4a0e1603c..e38128796 100644 --- a/docs/ephemeral-resources/access_token.md +++ b/docs/ephemeral-resources/access_token.md @@ -3,13 +3,16 @@ 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. Token generation logic prioritizes environment variables first, followed by provider configuration. Access tokens generated from service account keys expire after 60 minutes. + 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 generation 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. Token generation logic prioritizes environment variables first, followed by provider configuration. Access tokens generated from service account keys expire after 60 minutes. +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 generation 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. diff --git a/stackit/internal/services/access_token/ephemeral_resource.go b/stackit/internal/services/access_token/ephemeral_resource.go index 1c36fa62a..224512a91 100644 --- a/stackit/internal/services/access_token/ephemeral_resource.go +++ b/stackit/internal/services/access_token/ephemeral_resource.go @@ -36,7 +36,7 @@ func (e *accessTokenEphemeralResource) Configure(ctx context.Context, req epheme features.CheckBetaResourcesEnabled( ctx, - &core.ProviderData{EnableBetaResources: ephemeralProviderData.EnableBetaResources}, + &ephemeralProviderData.ProviderData, &resp.Diagnostics, "stackit_access_token", "ephemeral_resource", ) @@ -62,12 +62,21 @@ func (e *accessTokenEphemeralResource) Metadata(_ context.Context, req ephemeral } 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 generation will fail with an error.", + ), + core.EphemeralResource, + ) + resp.Schema = schema.Schema{ - Description: features.AddBetaDescription("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. "+ - "Token generation logic prioritizes environment variables first, followed by provider configuration. "+ - "Access tokens generated from service account keys expire after 60 minutes.", core.EphemeralResource), + Description: description, Attributes: map[string]schema.Attribute{ "access_token": schema.StringAttribute{ Description: "JWT access token for STACKIT API authentication.", From 8d43a782d4519dcf75458a0958bcfac2e86cf4c8 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Fri, 28 Nov 2025 16:22:37 +0100 Subject: [PATCH 09/11] review changes 5 (fix typo) Signed-off-by: Mauritz Uphoff --- stackit/internal/services/access_token/ephemeral_resource.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackit/internal/services/access_token/ephemeral_resource.go b/stackit/internal/services/access_token/ephemeral_resource.go index 224512a91..8ae346ba3 100644 --- a/stackit/internal/services/access_token/ephemeral_resource.go +++ b/stackit/internal/services/access_token/ephemeral_resource.go @@ -70,7 +70,7 @@ func (e *accessTokenEphemeralResource) Schema(_ context.Context, _ ephemeral.Sch "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 generation will fail with an error.", + "If any other authentication method is configured, this ephemeral resource will fail with an error.", ), core.EphemeralResource, ) From c45e7c84e9b63a1a9bc0d62b4ef051b195e0214c Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Fri, 28 Nov 2025 16:24:53 +0100 Subject: [PATCH 10/11] review changes 7 (ephemeralProviderData) Signed-off-by: Mauritz Uphoff --- docs/ephemeral-resources/access_token.md | 4 ++-- stackit/provider.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ephemeral-resources/access_token.md b/docs/ephemeral-resources/access_token.md index e38128796..35d83a000 100644 --- a/docs/ephemeral-resources/access_token.md +++ b/docs/ephemeral-resources/access_token.md @@ -4,7 +4,7 @@ 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 generation will fail with an error. + ~> 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. --- @@ -12,7 +12,7 @@ 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 generation will fail with an error. +~> 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. diff --git a/stackit/provider.go b/stackit/provider.go index f80838829..2d950bc78 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -476,12 +476,12 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, // 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 }) - setBoolField(providerConfig.EnableBetaResources, func(v bool) { ephemeralProviderData.EnableBetaResources = v }) resp.EphemeralResourceData = ephemeralProviderData providerData.Version = p.version From c0ea5a64bef32330cd66a773fd8d8ef5235f9c7a Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Tue, 2 Dec 2025 11:20:54 +0100 Subject: [PATCH 11/11] review changes 8 (improve logging) Signed-off-by: Mauritz Uphoff --- stackit/internal/conversion/conversion.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stackit/internal/conversion/conversion.go b/stackit/internal/conversion/conversion.go index 1017e4334..a0a4c945b 100644 --- a/stackit/internal/conversion/conversion.go +++ b/stackit/internal/conversion/conversion.go @@ -178,7 +178,7 @@ 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 @@ -192,7 +192,7 @@ func ParseEphemeralProviderData(ctx context.Context, providerData any, diags *di stackitProviderData, ok := providerData.(core.EphemeralProviderData) 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", "Expected configure type core.EphemeralProviderData") return core.EphemeralProviderData{}, false } return stackitProviderData, true