Skip to content

Commit 6641b0c

Browse files
committed
feat(access-token): add ephemeral access-token resource
Signed-off-by: Mauritz Uphoff <[email protected]>
1 parent 7709986 commit 6641b0c

File tree

5 files changed

+280
-2
lines changed

5 files changed

+280
-2
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "stackit_access_token Ephemeral Resource - stackit"
4+
subcategory: ""
5+
description: |-
6+
STACKIT Access Token ephemeral resource schema.
7+
---
8+
9+
# stackit_access_token (Ephemeral Resource)
10+
11+
STACKIT Access Token ephemeral resource schema.
12+
13+
## Example Usage
14+
15+
```terraform
16+
ephemeral "stackit_access_token" "example" {}
17+
18+
// https://registry.terraform.io/providers/Mastercard/restapi/latest/docs
19+
provider "restapi" {
20+
alias = "stackit_iaas"
21+
uri = "https://iaas.api.eu01.stackit.cloud"
22+
write_returns_object = true
23+
24+
headers = {
25+
"Authorization" = "Bearer ${ephemeral.stackit_access_token.example.access_token}"
26+
}
27+
28+
create_method = "GET"
29+
update_method = "GET"
30+
destroy_method = "GET"
31+
}
32+
```
33+
34+
<!-- schema generated by tfplugindocs -->
35+
## Schema
36+
37+
### Read-Only
38+
39+
- `access_token` (String, Sensitive) JWT access token for STACKIT API authentication.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
ephemeral "stackit_access_token" "example" {}
2+
3+
// https://registry.terraform.io/providers/Mastercard/restapi/latest/docs
4+
provider "restapi" {
5+
alias = "stackit_iaas"
6+
uri = "https://iaas.api.eu01.stackit.cloud"
7+
write_returns_object = true
8+
9+
headers = {
10+
"Authorization" = "Bearer ${ephemeral.stackit_access_token.example.access_token}"
11+
}
12+
13+
create_method = "GET"
14+
update_method = "GET"
15+
destroy_method = "GET"
16+
}

stackit/internal/core/core.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ const (
2828
type ProviderData struct {
2929
RoundTripper http.RoundTripper
3030
ServiceAccountEmail string // Deprecated: ServiceAccountEmail is not required and will be removed after 12th June 2025.
31+
32+
PrivateKey string
33+
PrivateKeyPath string
34+
ServiceAccountKey string
35+
ServiceAccountKeyPath string
36+
3137
// Deprecated: Use DefaultRegion instead
3238
Region string
3339
DefaultRegion string
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package access_token
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
9+
"github.com/hashicorp/terraform-plugin-framework/diag"
10+
"github.com/hashicorp/terraform-plugin-framework/ephemeral"
11+
"github.com/hashicorp/terraform-plugin-framework/ephemeral/schema"
12+
"github.com/hashicorp/terraform-plugin-framework/types"
13+
"github.com/hashicorp/terraform-plugin-log/tflog"
14+
"github.com/stackitcloud/stackit-sdk-go/core/clients"
15+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
16+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
17+
)
18+
19+
// #nosec G101 tokenUrl is a public endpoint, not a hardcoded credential
20+
const tokenUrl = "https://service-account.api.stackit.cloud/token"
21+
22+
var (
23+
_ ephemeral.EphemeralResource = &accessTokenEphemeralResource{}
24+
_ ephemeral.EphemeralResourceWithConfigure = &accessTokenEphemeralResource{}
25+
)
26+
27+
func NewAccessTokenEphemeralResource() ephemeral.EphemeralResource {
28+
return &accessTokenEphemeralResource{}
29+
}
30+
31+
type accessTokenEphemeralResource struct {
32+
serviceAccountKeyPath string
33+
serviceAccountKey string
34+
privateKeyPath string
35+
privateKey string
36+
}
37+
38+
func (e *accessTokenEphemeralResource) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) {
39+
providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
40+
if !ok {
41+
return
42+
}
43+
44+
e.serviceAccountKey = providerData.ServiceAccountKey
45+
e.serviceAccountKeyPath = providerData.ServiceAccountKeyPath
46+
e.privateKey = providerData.PrivateKey
47+
e.privateKeyPath = providerData.PrivateKeyPath
48+
}
49+
50+
type ephemeralTokenModel struct {
51+
AccessToken types.String `tfsdk:"access_token"`
52+
}
53+
54+
func (e *accessTokenEphemeralResource) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) {
55+
resp.TypeName = req.ProviderTypeName + "_access_token"
56+
}
57+
58+
func (e *accessTokenEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) {
59+
resp.Schema = schema.Schema{
60+
Description: "STACKIT Access Token ephemeral resource schema.",
61+
Attributes: map[string]schema.Attribute{
62+
"access_token": schema.StringAttribute{
63+
Description: "JWT access token for STACKIT API authentication.",
64+
Computed: true,
65+
Sensitive: true,
66+
},
67+
},
68+
}
69+
}
70+
71+
func (e *accessTokenEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) {
72+
var model ephemeralTokenModel
73+
74+
resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
75+
if resp.Diagnostics.HasError() {
76+
return
77+
}
78+
79+
serviceAccountKey, diags := loadServiceAccountKey(ctx, e.serviceAccountKey, e.serviceAccountKeyPath)
80+
resp.Diagnostics.Append(diags...)
81+
if resp.Diagnostics.HasError() {
82+
return
83+
}
84+
85+
privateKey, diags := resolvePrivateKey(ctx, e.privateKey, e.privateKeyPath, serviceAccountKey)
86+
resp.Diagnostics.Append(diags...)
87+
if resp.Diagnostics.HasError() {
88+
return
89+
}
90+
91+
client, diags := initKeyFlowClient(ctx, serviceAccountKey, privateKey)
92+
resp.Diagnostics.Append(diags...)
93+
if resp.Diagnostics.HasError() {
94+
return
95+
}
96+
97+
accessToken, err := client.GetAccessToken()
98+
if err != nil {
99+
core.LogAndAddError(ctx, &resp.Diagnostics, "Access token generation failed", fmt.Sprintf("Error generating access token: %v", err))
100+
return
101+
}
102+
103+
ctx = tflog.SetField(ctx, "access_token", accessToken)
104+
model.AccessToken = types.StringValue(accessToken)
105+
resp.Diagnostics.Append(resp.Result.Set(ctx, model)...)
106+
}
107+
108+
// loadServiceAccountKey loads the service account key based on env vars, or fallback to provider config.
109+
func loadServiceAccountKey(ctx context.Context, cfgValue, cfgPath string) (*clients.ServiceAccountKeyResponse, diag.Diagnostics) {
110+
var diags diag.Diagnostics
111+
112+
env := os.Getenv("STACKIT_SERVICE_ACCOUNT_KEY")
113+
envPath := os.Getenv("STACKIT_SERVICE_ACCOUNT_KEY_PATH")
114+
115+
var data []byte
116+
switch {
117+
case env != "":
118+
data = []byte(env)
119+
case envPath != "":
120+
b, err := os.ReadFile(envPath)
121+
if err != nil {
122+
core.LogAndAddError(ctx, &diags, "Failed to read service account key file (env path)", fmt.Sprintf("Error reading key file: %v", err))
123+
return nil, diags
124+
}
125+
data = b
126+
case cfgValue != "":
127+
data = []byte(cfgValue)
128+
case cfgPath != "":
129+
b, err := os.ReadFile(cfgPath)
130+
if err != nil {
131+
core.LogAndAddError(ctx, &diags, "Failed to read service account key file (provider path)", fmt.Sprintf("Error reading key file: %v", err))
132+
return nil, diags
133+
}
134+
data = b
135+
default:
136+
core.LogAndAddError(ctx, &diags, "Missing service account key", "Neither STACKIT_SERVICE_ACCOUNT_KEY, STACKIT_SERVICE_ACCOUNT_KEY_PATH, provider value, nor path were provided.")
137+
return nil, diags
138+
}
139+
140+
var key clients.ServiceAccountKeyResponse
141+
if err := json.Unmarshal(data, &key); err != nil {
142+
core.LogAndAddError(ctx, &diags, "Failed to parse service account key", fmt.Sprintf("Unmarshal error: %v", err))
143+
return nil, diags
144+
}
145+
146+
return &key, diags
147+
}
148+
149+
// resolvePrivateKey determines the private key value using env, conf, fallbacks.
150+
func resolvePrivateKey(ctx context.Context, cfgValue, cfgPath string, key *clients.ServiceAccountKeyResponse) (string, diag.Diagnostics) {
151+
var diags diag.Diagnostics
152+
153+
env := os.Getenv("STACKIT_PRIVATE_KEY")
154+
envPath := os.Getenv("STACKIT_PRIVATE_KEY_PATH")
155+
156+
switch {
157+
case env != "":
158+
return env, diags
159+
case envPath != "":
160+
content, err := os.ReadFile(envPath)
161+
if err != nil {
162+
core.LogAndAddError(ctx, &diags, "Failed to read private key file (env path)", fmt.Sprintf("Error: %v", err))
163+
return "", diags
164+
}
165+
return string(content), diags
166+
case cfgValue != "":
167+
return cfgValue, diags
168+
case cfgPath != "":
169+
content, err := os.ReadFile(cfgPath)
170+
if err != nil {
171+
core.LogAndAddError(ctx, &diags, "Failed to read private key file (provider path)", fmt.Sprintf("Error: %v", err))
172+
return "", diags
173+
}
174+
return string(content), diags
175+
case key.Credentials != nil && key.Credentials.PrivateKey != nil:
176+
return *key.Credentials.PrivateKey, diags
177+
default:
178+
core.LogAndAddError(ctx, &diags, "Missing private key", "No private key set via env, provider, or service account credentials.")
179+
return "", diags
180+
}
181+
}
182+
183+
// initKeyFlowClient configures and initializes a new KeyFlow client using the key and private key.
184+
func initKeyFlowClient(ctx context.Context, key *clients.ServiceAccountKeyResponse, privateKey string) (*clients.KeyFlow, diag.Diagnostics) {
185+
var diags diag.Diagnostics
186+
187+
client := &clients.KeyFlow{}
188+
cfg := &clients.KeyFlowConfig{
189+
ServiceAccountKey: key,
190+
PrivateKey: privateKey,
191+
TokenUrl: tokenUrl,
192+
}
193+
194+
if err := client.Init(cfg); err != nil {
195+
core.LogAndAddError(ctx, &diags, "Failed to initialize KeyFlow", fmt.Sprintf("KeyFlow client init error: %v", err))
196+
return nil, diags
197+
}
198+
199+
return client, diags
200+
}

stackit/provider.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
99
"github.com/hashicorp/terraform-plugin-framework/datasource"
10+
"github.com/hashicorp/terraform-plugin-framework/ephemeral"
1011
"github.com/hashicorp/terraform-plugin-framework/path"
1112
"github.com/hashicorp/terraform-plugin-framework/provider"
1213
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
@@ -18,6 +19,7 @@ import (
1819
"github.com/stackitcloud/stackit-sdk-go/core/config"
1920
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
2021
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
22+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/access_token"
2123
roleAssignements "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/authorization/roleassignments"
2224
cdnCustomDomain "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/customdomain"
2325
cdn "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/distribution"
@@ -97,7 +99,8 @@ import (
9799

98100
// Ensure the implementation satisfies the expected interfaces
99101
var (
100-
_ provider.Provider = &Provider{}
102+
_ provider.Provider = &Provider{}
103+
_ provider.ProviderWithEphemeralResources = &Provider{}
101104
)
102105

103106
// Provider is the provider implementation.
@@ -415,7 +418,6 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest,
415418
setStringField(providerConfig.Token, func(v string) { sdkConfig.Token = v })
416419
setStringField(providerConfig.TokenCustomEndpoint, func(v string) { sdkConfig.TokenCustomUrl = v })
417420

418-
// Provider Data Configuration
419421
setStringField(providerConfig.DefaultRegion, func(v string) { providerData.DefaultRegion = v })
420422
setStringField(providerConfig.Region, func(v string) { providerData.Region = v }) // nolint:staticcheck // preliminary handling of deprecated attribute
421423
setStringField(providerConfig.CdnCustomEndpoint, func(v string) { providerData.CdnCustomEndpoint = v })
@@ -465,6 +467,14 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest,
465467
resp.DataSourceData = providerData
466468
resp.ResourceData = providerData
467469

470+
// Copy service account and private key credentials to support ephemeral access token generation
471+
ephemeralProviderData := providerData
472+
setStringField(providerConfig.ServiceAccountKey, func(v string) { ephemeralProviderData.ServiceAccountKey = v })
473+
setStringField(providerConfig.ServiceAccountKeyPath, func(v string) { ephemeralProviderData.ServiceAccountKeyPath = v })
474+
setStringField(providerConfig.PrivateKey, func(v string) { ephemeralProviderData.PrivateKey = v })
475+
setStringField(providerConfig.PrivateKeyPath, func(v string) { ephemeralProviderData.PrivateKeyPath = v })
476+
resp.EphemeralResourceData = ephemeralProviderData
477+
468478
providerData.Version = p.version
469479
}
470480

@@ -615,3 +625,10 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource {
615625

616626
return resources
617627
}
628+
629+
// EphemeralResources defines the ephemeral resources implemented in the provider.
630+
func (p *Provider) EphemeralResources(_ context.Context) []func() ephemeral.EphemeralResource {
631+
return []func() ephemeral.EphemeralResource{
632+
access_token.NewAccessTokenEphemeralResource,
633+
}
634+
}

0 commit comments

Comments
 (0)