feat(access-token): add ephemeral access-token resource (#1068)
* feat(access-token): add ephemeral access-token resource Signed-off-by: Mauritz Uphoff <mauritz.uphoff@stackit.cloud>
This commit is contained in:
parent
368b8d55be
commit
0e9b97a513
12 changed files with 733 additions and 5 deletions
73
docs/ephemeral-resources/access_token.md
Normal file
73
docs/ephemeral-resources/access_token.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
# 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/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 generated by tfplugindocs -->
|
||||
## Schema
|
||||
|
||||
### Read-Only
|
||||
|
||||
- `access_token` (String, Sensitive) JWT access token for STACKIT API authentication.
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
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/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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`)),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
132
stackit/internal/services/access_token/ephemeral_resource.go
Normal file
132
stackit/internal/services/access_token/ephemeral_resource.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
15
stackit/internal/services/access_token/testdata/ephemeral_resource.tf
vendored
Normal file
15
stackit/internal/services/access_token/testdata/ephemeral_resource.tf
vendored
Normal file
|
|
@ -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" {
|
||||
}
|
||||
16
stackit/internal/services/access_token/testdata/service_account.json
vendored
Normal file
16
stackit/internal/services/access_token/testdata/service_account.json
vendored
Normal file
|
|
@ -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-----"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue