Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions docs/ephemeral-resources/access_token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_access_token Ephemeral Resource - stackit"
subcategory: ""
description: |-
Ephemeral resource that generates a short-lived STACKIT access token (JWT) using a service account key. A new token is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation. If a private key is not explicitly provided, the provider attempts to extract it from the service account key instead. Access tokens generated from service account keys expire after 60 minutes.
~> Service account key credentials must be configured either in the STACKIT provider configuration or via environment variables (see example below). If any other authentication method is configured, this ephemeral resource will fail with an error.
~> This ephemeral-resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---

# stackit_access_token (Ephemeral Resource)

Ephemeral resource that generates a short-lived STACKIT access token (JWT) using a service account key. A new token is generated each time the resource is evaluated, and it remains consistent for the duration of a Terraform operation. If a private key is not explicitly provided, the provider attempts to extract it from the service account key instead. Access tokens generated from service account keys expire after 60 minutes.

~> Service account key credentials must be configured either in the STACKIT provider configuration or via environment variables (see example below). If any other authentication method is configured, this ephemeral resource will fail with an error.

~> This ephemeral-resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.

## Example Usage

```terraform
provider "stackit" {
default_region = "eu01"
service_account_key_path = "/path/to/sa_key.json"
enable_beta_resources = true
}

ephemeral "stackit_access_token" "example" {}

locals {
stackit_api_base_url = "https://iaas.api.stackit.cloud"
public_ip_path = "/v2/projects/${var.project_id}/regions/${var.region}/public-ips"

public_ip_payload = {
labels = {
key = "value"
}
}
}

# Docs: https://registry.terraform.io/providers/magodo/restful/latest
provider "restful" {
base_url = local.stackit_api_base_url

security = {
http = {
token = {
token = ephemeral.stackit_access_token.example.access_token
}
}
}
}

resource "restful_resource" "public_ip_restful" {
path = local.public_ip_path
body = local.public_ip_payload

read_path = "$(path)/$(body.id)"
update_path = "$(path)/$(body.id)"
update_method = "PATCH"
delete_path = "$(path)/$(body.id)"
delete_method = "DELETE"
}

# Docs: https://registry.terraform.io/providers/Mastercard/restapi/latest
provider "restapi" {
uri = local.stackit_api_base_url
write_returns_object = true

headers = {
Authorization = "Bearer ${ephemeral.stackit_access_token.example.access_token}"
Content-Type = "application/json"
}

create_method = "POST"
update_method = "PATCH"
destroy_method = "DELETE"
}

resource "restapi_object" "public_ip_restapi" {
path = local.public_ip_path
data = jsonencode(local.public_ip_payload)

id_attribute = "id"
read_method = "GET"
create_method = "POST"
update_method = "PATCH"
destroy_method = "DELETE"
}
```

<!-- schema generated by tfplugindocs -->
## Schema

### Read-Only

- `access_token` (String, Sensitive) JWT access token for STACKIT API authentication.
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
provider "stackit" {
default_region = "eu01"
service_account_key_path = "/path/to/sa_key.json"
enable_beta_resources = true
}

ephemeral "stackit_access_token" "example" {}

locals {
stackit_api_base_url = "https://iaas.api.stackit.cloud"
public_ip_path = "/v2/projects/${var.project_id}/regions/${var.region}/public-ips"

public_ip_payload = {
labels = {
key = "value"
}
}
}

# Docs: https://registry.terraform.io/providers/magodo/restful/latest
provider "restful" {
base_url = local.stackit_api_base_url

security = {
http = {
token = {
token = ephemeral.stackit_access_token.example.access_token
}
}
}
}

resource "restful_resource" "public_ip_restful" {
path = local.public_ip_path
body = local.public_ip_payload

read_path = "$(path)/$(body.id)"
update_path = "$(path)/$(body.id)"
update_method = "PATCH"
delete_path = "$(path)/$(body.id)"
delete_method = "DELETE"
}

# Docs: https://registry.terraform.io/providers/Mastercard/restapi/latest
provider "restapi" {
uri = local.stackit_api_base_url
write_returns_object = true

headers = {
Authorization = "Bearer ${ephemeral.stackit_access_token.example.access_token}"
Content-Type = "application/json"
}

create_method = "POST"
update_method = "PATCH"
destroy_method = "DELETE"
}

resource "restapi_object" "public_ip_restapi" {
path = local.public_ip_path
data = jsonencode(local.public_ip_payload)

id_attribute = "id"
read_method = "GET"
create_method = "POST"
update_method = "PATCH"
destroy_method = "DELETE"
}
16 changes: 15 additions & 1 deletion stackit/internal/conversion/conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
88 changes: 88 additions & 0 deletions stackit/internal/conversion/conversion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
15 changes: 13 additions & 2 deletions stackit/internal/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ","
Expand All @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions stackit/internal/services/access_token/access_token_acc_test.go
Original file line number Diff line number Diff line change
@@ -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`)),
),
},
},
},
})
}
Loading
Loading