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:
Mauritz Uphoff 2025-12-03 10:13:28 +01:00 committed by GitHub
parent 368b8d55be
commit 0e9b97a513
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 733 additions and 5 deletions

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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.

View file

@ -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`)),
),
},
},
},
})
}

View 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
}

View file

@ -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)
}
}
})
}
}

View 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" {
}

View 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-----"
}
}

View file

@ -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"

View file

@ -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,
}
}