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
39 changes: 39 additions & 0 deletions docs/ephemeral-resources/access_token.md
Original file line number Diff line number Diff line change
@@ -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 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,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"
}
6 changes: 6 additions & 0 deletions stackit/internal/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we have a seperate struct for that? E.g. by using https://gobyexample.com/struct-embedding

type EphermalProviderData struct {
    ProviderData
    
    PrivateKey            string
    PrivateKeyPath        string
	ServiceAccountKey     string
	ServiceAccountKeyPath string
}

The fields like PrivateKey, ... don't belong into regular resources and datasources. You don't even set them there (what is good of course 😅). So some abstraction won't hurt here to keep things clean.

PrivateKeyPath string
ServiceAccountKey string
ServiceAccountKeyPath string

// Deprecated: Use DefaultRegion instead
Region string
DefaultRegion string
Expand Down
200 changes: 200 additions & 0 deletions stackit/internal/services/access_token/ephemeral_access_token.go
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other question, what's with custom endpoints?


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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a HUGE no no, please don't ever (!!) log credentials

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")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this be done already by the provider logic? I'm not willing to read env variables here...

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")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this be done already by the provider logic? I'm not willing to read env variables here...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's not done there, it belongs there. This doesn't belong into some resource implementation for sure.

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
}
21 changes: 19 additions & 2 deletions stackit/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -415,7 +418,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
setStringField(providerConfig.CdnCustomEndpoint, func(v string) { providerData.CdnCustomEndpoint = v })
Expand Down Expand Up @@ -465,6 +467,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
}

Expand Down Expand Up @@ -615,3 +625,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