Skip to content

Commit 0e9b97a

Browse files
authored
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>
1 parent 368b8d5 commit 0e9b97a

File tree

12 files changed

+733
-5
lines changed

12 files changed

+733
-5
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
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.
7+
~> 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.
8+
~> 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.
9+
---
10+
11+
# stackit_access_token (Ephemeral Resource)
12+
13+
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.
14+
15+
~> 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.
16+
17+
~> 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.
18+
19+
## Example Usage
20+
21+
```terraform
22+
provider "stackit" {
23+
default_region = "eu01"
24+
service_account_key_path = "/path/to/sa_key.json"
25+
enable_beta_resources = true
26+
}
27+
28+
ephemeral "stackit_access_token" "example" {}
29+
30+
locals {
31+
stackit_api_base_url = "https://iaas.api.stackit.cloud"
32+
public_ip_path = "/v2/projects/${var.project_id}/regions/${var.region}/public-ips"
33+
34+
public_ip_payload = {
35+
labels = {
36+
key = "value"
37+
}
38+
}
39+
}
40+
41+
# Docs: https://registry.terraform.io/providers/Mastercard/restapi/latest
42+
provider "restapi" {
43+
uri = local.stackit_api_base_url
44+
write_returns_object = true
45+
46+
headers = {
47+
Authorization = "Bearer ${ephemeral.stackit_access_token.example.access_token}"
48+
Content-Type = "application/json"
49+
}
50+
51+
create_method = "POST"
52+
update_method = "PATCH"
53+
destroy_method = "DELETE"
54+
}
55+
56+
resource "restapi_object" "public_ip_restapi" {
57+
path = local.public_ip_path
58+
data = jsonencode(local.public_ip_payload)
59+
60+
id_attribute = "id"
61+
read_method = "GET"
62+
create_method = "POST"
63+
update_method = "PATCH"
64+
destroy_method = "DELETE"
65+
}
66+
```
67+
68+
<!-- schema generated by tfplugindocs -->
69+
## Schema
70+
71+
### Read-Only
72+
73+
- `access_token` (String, Sensitive) JWT access token for STACKIT API authentication.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
provider "stackit" {
2+
default_region = "eu01"
3+
service_account_key_path = "/path/to/sa_key.json"
4+
enable_beta_resources = true
5+
}
6+
7+
ephemeral "stackit_access_token" "example" {}
8+
9+
locals {
10+
stackit_api_base_url = "https://iaas.api.stackit.cloud"
11+
public_ip_path = "/v2/projects/${var.project_id}/regions/${var.region}/public-ips"
12+
13+
public_ip_payload = {
14+
labels = {
15+
key = "value"
16+
}
17+
}
18+
}
19+
20+
# Docs: https://registry.terraform.io/providers/Mastercard/restapi/latest
21+
provider "restapi" {
22+
uri = local.stackit_api_base_url
23+
write_returns_object = true
24+
25+
headers = {
26+
Authorization = "Bearer ${ephemeral.stackit_access_token.example.access_token}"
27+
Content-Type = "application/json"
28+
}
29+
30+
create_method = "POST"
31+
update_method = "PATCH"
32+
destroy_method = "DELETE"
33+
}
34+
35+
resource "restapi_object" "public_ip_restapi" {
36+
path = local.public_ip_path
37+
data = jsonencode(local.public_ip_payload)
38+
39+
id_attribute = "id"
40+
read_method = "GET"
41+
create_method = "POST"
42+
update_method = "PATCH"
43+
destroy_method = "DELETE"
44+
}

stackit/internal/conversion/conversion.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,22 @@ func ParseProviderData(ctx context.Context, providerData any, diags *diag.Diagno
178178

179179
stackitProviderData, ok := providerData.(core.ProviderData)
180180
if !ok {
181-
core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", providerData))
181+
core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Expected configure type core.ProviderData, got %T", providerData))
182182
return core.ProviderData{}, false
183183
}
184184
return stackitProviderData, true
185185
}
186+
187+
func ParseEphemeralProviderData(ctx context.Context, providerData any, diags *diag.Diagnostics) (core.EphemeralProviderData, bool) {
188+
// Prevent panic if the provider has not been configured.
189+
if providerData == nil {
190+
return core.EphemeralProviderData{}, false
191+
}
192+
193+
stackitProviderData, ok := providerData.(core.EphemeralProviderData)
194+
if !ok {
195+
core.LogAndAddError(ctx, diags, "Error configuring API client", "Expected configure type core.EphemeralProviderData")
196+
return core.EphemeralProviderData{}, false
197+
}
198+
return stackitProviderData, true
199+
}

stackit/internal/conversion/conversion_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,3 +304,91 @@ func TestParseProviderData(t *testing.T) {
304304
})
305305
}
306306
}
307+
308+
func TestParseEphemeralProviderData(t *testing.T) {
309+
type args struct {
310+
providerData any
311+
}
312+
type want struct {
313+
ok bool
314+
providerData core.EphemeralProviderData
315+
}
316+
tests := []struct {
317+
name string
318+
args args
319+
want want
320+
wantErr bool
321+
}{
322+
{
323+
name: "provider has not been configured",
324+
args: args{
325+
providerData: nil,
326+
},
327+
want: want{
328+
ok: false,
329+
},
330+
wantErr: false,
331+
},
332+
{
333+
name: "invalid provider data",
334+
args: args{
335+
providerData: struct{}{},
336+
},
337+
want: want{
338+
ok: false,
339+
},
340+
wantErr: true,
341+
},
342+
{
343+
name: "valid provider data 1",
344+
args: args{
345+
providerData: core.EphemeralProviderData{},
346+
},
347+
want: want{
348+
ok: true,
349+
providerData: core.EphemeralProviderData{},
350+
},
351+
wantErr: false,
352+
},
353+
{
354+
name: "valid provider data 2",
355+
args: args{
356+
providerData: core.EphemeralProviderData{
357+
PrivateKey: "",
358+
PrivateKeyPath: "/home/dev/foo/private-key.json",
359+
ServiceAccountKey: "",
360+
ServiceAccountKeyPath: "/home/dev/foo/key.json",
361+
TokenCustomEndpoint: "",
362+
},
363+
},
364+
want: want{
365+
ok: true,
366+
providerData: core.EphemeralProviderData{
367+
PrivateKey: "",
368+
PrivateKeyPath: "/home/dev/foo/private-key.json",
369+
ServiceAccountKey: "",
370+
ServiceAccountKeyPath: "/home/dev/foo/key.json",
371+
TokenCustomEndpoint: "",
372+
},
373+
},
374+
wantErr: false,
375+
},
376+
}
377+
for _, tt := range tests {
378+
t.Run(tt.name, func(t *testing.T) {
379+
ctx := context.Background()
380+
diags := diag.Diagnostics{}
381+
382+
actual, ok := ParseEphemeralProviderData(ctx, tt.args.providerData, &diags)
383+
if diags.HasError() != tt.wantErr {
384+
t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr)
385+
}
386+
if ok != tt.want.ok {
387+
t.Errorf("ParseProviderData() got = %v, want %v", ok, tt.want.ok)
388+
}
389+
if !reflect.DeepEqual(actual, tt.want.providerData) {
390+
t.Errorf("ParseProviderData() got = %v, want %v", actual, tt.want)
391+
}
392+
})
393+
}
394+
}

stackit/internal/core/core.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ import (
1616
type ResourceType string
1717

1818
const (
19-
Resource ResourceType = "resource"
20-
Datasource ResourceType = "datasource"
19+
Resource ResourceType = "resource"
20+
Datasource ResourceType = "datasource"
21+
EphemeralResource ResourceType = "ephemeral-resource"
2122

2223
// Separator used for concatenation of TF-internal resource ID
2324
Separator = ","
@@ -26,6 +27,16 @@ const (
2627
DatasourceRegionFallbackDocstring = "Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on datasource level."
2728
)
2829

30+
type EphemeralProviderData struct {
31+
ProviderData
32+
33+
PrivateKey string
34+
PrivateKeyPath string
35+
ServiceAccountKey string
36+
ServiceAccountKeyPath string
37+
TokenCustomEndpoint string
38+
}
39+
2940
type ProviderData struct {
3041
RoundTripper http.RoundTripper
3142
ServiceAccountEmail string // Deprecated: ServiceAccountEmail is not required and will be removed after 12th June 2025.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package access_token_test
2+
3+
import (
4+
_ "embed"
5+
"regexp"
6+
"testing"
7+
8+
"github.com/hashicorp/terraform-plugin-testing/config"
9+
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
10+
"github.com/hashicorp/terraform-plugin-testing/knownvalue"
11+
"github.com/hashicorp/terraform-plugin-testing/statecheck"
12+
"github.com/hashicorp/terraform-plugin-testing/tfjsonpath"
13+
"github.com/hashicorp/terraform-plugin-testing/tfversion"
14+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
15+
)
16+
17+
//go:embed testdata/ephemeral_resource.tf
18+
var ephemeralResourceConfig string
19+
20+
var testConfigVars = config.Variables{
21+
"default_region": config.StringVariable(testutil.Region),
22+
}
23+
24+
func TestAccEphemeralAccessToken(t *testing.T) {
25+
resource.Test(t, resource.TestCase{
26+
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
27+
tfversion.SkipBelow(tfversion.Version1_10_0),
28+
},
29+
ProtoV6ProviderFactories: testutil.TestEphemeralAccProtoV6ProviderFactories,
30+
Steps: []resource.TestStep{
31+
{
32+
Config: ephemeralResourceConfig,
33+
ConfigVariables: testConfigVars,
34+
ConfigStateChecks: []statecheck.StateCheck{
35+
statecheck.ExpectKnownValue(
36+
"echo.example",
37+
tfjsonpath.New("data").AtMapKey("access_token"),
38+
knownvalue.NotNull(),
39+
),
40+
// JWT access tokens start with "ey" because the first part is base64-encoded JSON that begins with "{".
41+
statecheck.ExpectKnownValue(
42+
"echo.example",
43+
tfjsonpath.New("data").AtMapKey("access_token"),
44+
knownvalue.StringRegexp(regexp.MustCompile(`^ey`)),
45+
),
46+
},
47+
},
48+
},
49+
})
50+
}

0 commit comments

Comments
 (0)