Skip to content

Commit 5b05612

Browse files
Merge pull request #996 from Juniper/ephemeral-api-token
Introduce API Token Ephemeral Resource
2 parents 4513764 + a1cedca commit 5b05612

27 files changed

+1478
-233
lines changed

Third_Party_Code/NOTICES.md

+387-12
Large diffs are not rendered by default.

Third_Party_Code/github.com/hashicorp/go-retryablehttp/LICENSE

+365
Large diffs are not rendered by default.

Third_Party_Code/golang.org/x/mod/LICENSE

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Copyright (c) 2009 The Go Authors. All rights reserved.
1+
Copyright 2009 The Go Authors.
22

33
Redistribution and use in source and binary forms, with or without
44
modification, are permitted provided that the following conditions are
@@ -10,7 +10,7 @@ notice, this list of conditions and the following disclaimer.
1010
copyright notice, this list of conditions and the following disclaimer
1111
in the documentation and/or other materials provided with the
1212
distribution.
13-
* Neither the name of Google Inc. nor the names of its
13+
* Neither the name of Google LLC nor the names of its
1414
contributors may be used to endorse or promote products derived from
1515
this software without specific prior written permission.
1616

apstra/authentication/token.go

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package authentication
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
"fmt"
8+
"strings"
9+
"time"
10+
11+
"github.com/Juniper/terraform-provider-apstra/apstra/private"
12+
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
13+
"github.com/hashicorp/terraform-plugin-framework/diag"
14+
ephemeralSchema "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema"
15+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
16+
"github.com/hashicorp/terraform-plugin-framework/types"
17+
)
18+
19+
const apiTokenDefaultWarning = 60
20+
21+
type ApiToken struct {
22+
Value types.String `tfsdk:"value"`
23+
SessionId types.String `tfsdk:"session_id"`
24+
UserName types.String `tfsdk:"user_name"`
25+
WarnSeconds types.Int64 `tfsdk:"warn_seconds"`
26+
ExpiresAt time.Time `tfsdk:"-"`
27+
DoNotLogOut types.Bool `tfsdk:"do_not_log_out"`
28+
}
29+
30+
func (o ApiToken) EphemeralAttributes() map[string]ephemeralSchema.Attribute {
31+
return map[string]ephemeralSchema.Attribute{
32+
"value": ephemeralSchema.StringAttribute{
33+
Computed: true,
34+
MarkdownDescription: "The API token value.",
35+
},
36+
"session_id": ephemeralSchema.StringAttribute{
37+
Computed: true,
38+
MarkdownDescription: "The API session ID associated with the token.",
39+
},
40+
"user_name": ephemeralSchema.StringAttribute{
41+
Computed: true,
42+
MarkdownDescription: "The user name associated with the session ID.",
43+
},
44+
"warn_seconds": ephemeralSchema.Int64Attribute{
45+
Optional: true,
46+
Computed: true,
47+
MarkdownDescription: fmt.Sprintf("Terraform will produce a warning when the token value is "+
48+
"referenced with less than this amount of time remaining before expiration. Note that "+
49+
"determination of remaining token lifetime depends on clock sync between the Apstra server and "+
50+
"the Terraform host. Value `0` disables warnings. Default value is `%d`.", apiTokenDefaultWarning),
51+
Validators: []validator.Int64{int64validator.AtLeast(0)},
52+
},
53+
"do_not_log_out": ephemeralSchema.BoolAttribute{
54+
Optional: true,
55+
MarkdownDescription: "By default, API sessions are closed when Terraform's `Close` operation calls " +
56+
"`logout`. Set this value to `true` to prevent ending the session when Terraform determines the " +
57+
"API key is no longer in use.",
58+
},
59+
}
60+
}
61+
62+
func (o *ApiToken) LoadApiData(_ context.Context, in string, diags *diag.Diagnostics) {
63+
parts := strings.Split(in, ".")
64+
if len(parts) != 3 {
65+
diags.AddError("unexpected API response", fmt.Sprintf("JWT should have 3 parts, got %d", len(parts)))
66+
return
67+
}
68+
69+
claimsB64 := parts[1] + strings.Repeat("=", (4-len(parts[1])%4)%4) // pad the b64 part as necessary
70+
claimsBytes, err := base64.StdEncoding.DecodeString(claimsB64)
71+
if err != nil {
72+
diags.AddError("failed base64 decoding token claims", err.Error())
73+
return
74+
}
75+
76+
var claims struct {
77+
Username string `json:"username"`
78+
UserSession string `json:"user_session"`
79+
Expiration int64 `json:"exp"`
80+
}
81+
err = json.Unmarshal(claimsBytes, &claims)
82+
if err != nil {
83+
diags.AddError("failed unmarshaling token claims JSON payload", err.Error())
84+
return
85+
}
86+
87+
o.Value = types.StringValue(in)
88+
o.UserName = types.StringValue(claims.Username)
89+
o.SessionId = types.StringValue(claims.UserSession)
90+
o.ExpiresAt = time.Unix(claims.Expiration, 0)
91+
}
92+
93+
func (o *ApiToken) SetDefaults() {
94+
if o.WarnSeconds.IsNull() {
95+
o.WarnSeconds = types.Int64Value(apiTokenDefaultWarning)
96+
}
97+
}
98+
99+
func (o *ApiToken) SetPrivateState(ctx context.Context, ps private.State, diags *diag.Diagnostics) {
100+
privateEphemeralApiToken := private.EphemeralApiToken{
101+
Token: o.Value.ValueString(),
102+
ExpiresAt: o.ExpiresAt,
103+
WarnThreshold: time.Duration(o.WarnSeconds.ValueInt64()) * time.Second,
104+
DoNotLogOut: o.DoNotLogOut.ValueBool(),
105+
}
106+
privateEphemeralApiToken.SetPrivateState(ctx, ps, diags)
107+
}

apstra/configure_data_source.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package tfapstra
33
import (
44
"context"
55
"fmt"
6+
67
"github.com/Juniper/apstra-go-sdk/apstra"
78
"github.com/hashicorp/terraform-plugin-framework/datasource"
89
)
@@ -48,5 +49,4 @@ func configureDataSource(_ context.Context, ds datasource.DataSourceWithConfigur
4849
if ds, ok := ds.(datasourceWithSetFfBpClientFunc); ok {
4950
ds.setBpClientFunc(pd.getFreeformClient)
5051
}
51-
5252
}

apstra/configure_ephemeral.go

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package tfapstra
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/Juniper/apstra-go-sdk/apstra"
8+
"github.com/hashicorp/terraform-plugin-framework/ephemeral"
9+
)
10+
11+
type ephemeralWithSetClient interface {
12+
ephemeral.EphemeralResourceWithConfigure
13+
setClient(*apstra.Client)
14+
}
15+
16+
type ephemeralWithSetDcBpClientFunc interface {
17+
ephemeral.EphemeralResourceWithConfigure
18+
setBpClientFunc(func(context.Context, string) (*apstra.TwoStageL3ClosClient, error))
19+
}
20+
21+
type ephemeralWithSetFfBpClientFunc interface {
22+
ephemeral.EphemeralResourceWithConfigure
23+
setBpClientFunc(func(context.Context, string) (*apstra.FreeformClient, error))
24+
}
25+
26+
func configureEphemeral(_ context.Context, ep ephemeral.EphemeralResourceWithConfigure, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) {
27+
if req.ProviderData == nil {
28+
return // cannot continue
29+
}
30+
31+
var pd *providerData
32+
var ok bool
33+
34+
if pd, ok = req.ProviderData.(*providerData); !ok {
35+
resp.Diagnostics.AddError(
36+
errDataSourceConfigureProviderDataSummary,
37+
fmt.Sprintf(errDataSourceConfigureProviderDataDetail, *pd, req.ProviderData),
38+
)
39+
}
40+
41+
if ep, ok := ep.(ephemeralWithSetClient); ok {
42+
ep.setClient(pd.client)
43+
}
44+
45+
if ep, ok := ep.(ephemeralWithSetDcBpClientFunc); ok {
46+
ep.setBpClientFunc(pd.getTwoStageL3ClosClient)
47+
}
48+
49+
if ep, ok := ep.(ephemeralWithSetFfBpClientFunc); ok {
50+
ep.setBpClientFunc(pd.getFreeformClient)
51+
}
52+
}

apstra/constants.go

+8-7
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@ const (
1919
errBpClientCreateSummary = "Failed to create client for Blueprint %s"
2020
errBpNotFoundSummary = "Blueprint %s not found"
2121

22-
docCategorySeparator = " --- "
23-
docCategoryDesign = "Design" + docCategorySeparator
24-
docCategoryResources = "Resource Pools" + docCategorySeparator
25-
docCategoryDatacenter = "Reference Design: Datacenter" + docCategorySeparator
26-
docCategoryFreeform = "Reference Design: Freeform" + docCategorySeparator
27-
docCategoryRefDesignAny = "Reference Design: Shared" + docCategorySeparator
28-
docCategoryDevices = "Devices" + docCategorySeparator
22+
docCategorySeparator = " --- "
23+
docCategoryAuthentication = "Authentication" + docCategorySeparator
24+
docCategoryDatacenter = "Reference Design: Datacenter" + docCategorySeparator
25+
docCategoryDesign = "Design" + docCategorySeparator
26+
docCategoryDevices = "Devices" + docCategorySeparator
27+
docCategoryFreeform = "Reference Design: Freeform" + docCategorySeparator
28+
docCategoryRefDesignAny = "Reference Design: Shared" + docCategorySeparator
29+
docCategoryResources = "Resource Pools" + docCategorySeparator
2930
)

apstra/ephemeral_api_token.go

+187
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package tfapstra
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"time"
8+
9+
"github.com/Juniper/apstra-go-sdk/apstra"
10+
"github.com/Juniper/terraform-provider-apstra/apstra/authentication"
11+
"github.com/Juniper/terraform-provider-apstra/apstra/private"
12+
"github.com/hashicorp/terraform-plugin-framework/ephemeral"
13+
"github.com/hashicorp/terraform-plugin-framework/ephemeral/schema"
14+
)
15+
16+
var (
17+
_ ephemeral.EphemeralResource = (*ephemeralToken)(nil)
18+
_ ephemeral.EphemeralResourceWithClose = (*ephemeralToken)(nil)
19+
_ ephemeral.EphemeralResourceWithConfigure = (*ephemeralToken)(nil)
20+
_ ephemeral.EphemeralResourceWithRenew = (*ephemeralToken)(nil)
21+
_ ephemeralWithSetClient = (*ephemeralToken)(nil)
22+
)
23+
24+
type ephemeralToken struct {
25+
client *apstra.Client
26+
}
27+
28+
func (o *ephemeralToken) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) {
29+
resp.TypeName = req.ProviderTypeName + "_api_token"
30+
}
31+
32+
func (o *ephemeralToken) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) {
33+
resp.Schema = schema.Schema{
34+
MarkdownDescription: docCategoryAuthentication + "This Ephemeral Resource retrieves a unique API token and (optionally) invalidates it on Close.",
35+
Attributes: authentication.ApiToken{}.EphemeralAttributes(),
36+
}
37+
}
38+
39+
func (o *ephemeralToken) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) {
40+
configureEphemeral(ctx, o, req, resp)
41+
}
42+
43+
func (o *ephemeralToken) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) {
44+
var config authentication.ApiToken
45+
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
46+
if resp.Diagnostics.HasError() {
47+
return
48+
}
49+
50+
// set default values
51+
config.SetDefaults()
52+
53+
// create a new client using the credentials in the embedded client's config
54+
client, err := o.client.Config().NewClient(ctx)
55+
if err != nil {
56+
resp.Diagnostics.AddError("error creating new client", err.Error())
57+
return
58+
}
59+
60+
// log in so that the new client fetches an API token
61+
err = client.Login(ctx)
62+
if err != nil {
63+
resp.Diagnostics.AddError("error logging in new client", err.Error())
64+
return
65+
}
66+
67+
// extract the token
68+
token := client.GetApiToken()
69+
if token == "" {
70+
resp.Diagnostics.AddError("requested API token is empty", "requested API token is empty")
71+
return
72+
}
73+
74+
// Destroy the new client without invalidating the API token we just collected.
75+
// We call Logout() here only for the side effect of stopping the task monitor
76+
// goroutine. This client *can't* invalidate the session because it no longer
77+
// has an API token.
78+
client.SetApiToken("")
79+
err = client.Logout(ctx)
80+
if err != nil {
81+
resp.Diagnostics.AddError("error logging out client", err.Error())
82+
return
83+
}
84+
85+
config.LoadApiData(ctx, token, &resp.Diagnostics)
86+
if resp.Diagnostics.HasError() {
87+
return
88+
}
89+
90+
// sanity check the token lifetime
91+
now := time.Now()
92+
if now.After(config.ExpiresAt) {
93+
resp.Diagnostics.AddError(
94+
"Just-fetched API token is expired",
95+
fmt.Sprintf("Token expired at: %s. Current time is: %s", config.ExpiresAt, now),
96+
)
97+
return
98+
}
99+
100+
// warn the user about imminent expiration
101+
warn := time.Duration(config.WarnSeconds.ValueInt64()) * time.Second
102+
if now.Add(warn).After(config.ExpiresAt) {
103+
resp.Diagnostics.AddWarning(
104+
fmt.Sprintf("API token expires within %d second warning threshold", config.WarnSeconds),
105+
fmt.Sprintf("API token expires at %s. Current time: %s", config.ExpiresAt, now),
106+
)
107+
}
108+
109+
// save the private state
110+
config.SetPrivateState(ctx, resp.Private, &resp.Diagnostics)
111+
if resp.Diagnostics.HasError() {
112+
return
113+
}
114+
115+
// set the renew timestamp to the early warning time
116+
resp.RenewAt = config.ExpiresAt.Add(-1 * warn)
117+
118+
// set the result
119+
resp.Diagnostics.Append(resp.Result.Set(ctx, &config)...)
120+
}
121+
122+
func (o *ephemeralToken) Renew(ctx context.Context, req ephemeral.RenewRequest, resp *ephemeral.RenewResponse) {
123+
var privateEphemeralApiToken private.EphemeralApiToken
124+
privateEphemeralApiToken.LoadPrivateState(ctx, req.Private, &resp.Diagnostics)
125+
if resp.Diagnostics.HasError() {
126+
return
127+
}
128+
129+
now := time.Now()
130+
if now.After(privateEphemeralApiToken.ExpiresAt) {
131+
resp.Diagnostics.AddError(
132+
"API token has expired",
133+
fmt.Sprintf("Token expired at: %s. Current time is: %s", privateEphemeralApiToken.ExpiresAt, now),
134+
)
135+
return
136+
}
137+
138+
if now.Add(privateEphemeralApiToken.WarnThreshold).After(privateEphemeralApiToken.ExpiresAt) {
139+
resp.Diagnostics.AddWarning(
140+
fmt.Sprintf("API token expires within %d second warning threshold", privateEphemeralApiToken.WarnThreshold),
141+
fmt.Sprintf("API token expires at %s. Current time: %s", privateEphemeralApiToken.ExpiresAt, now),
142+
)
143+
}
144+
145+
// set the renew timestamp to the expiration time
146+
resp.RenewAt = privateEphemeralApiToken.ExpiresAt
147+
}
148+
149+
func (o *ephemeralToken) Close(ctx context.Context, req ephemeral.CloseRequest, resp *ephemeral.CloseResponse) {
150+
// extract the private state data
151+
var privateEphemeralApiToken private.EphemeralApiToken
152+
privateEphemeralApiToken.LoadPrivateState(ctx, req.Private, &resp.Diagnostics)
153+
154+
if privateEphemeralApiToken.DoNotLogOut {
155+
return // user doesn't want the token invalidated, so there's nothing to do
156+
}
157+
158+
if time.Now().After(privateEphemeralApiToken.ExpiresAt) {
159+
return // token has already expired, so there's nothing to do
160+
}
161+
162+
// create a new client based on the embedded client's config
163+
client, err := o.client.Config().NewClient(ctx)
164+
if err != nil {
165+
resp.Diagnostics.AddError("error creating new client", err.Error())
166+
return
167+
}
168+
169+
// copy the API token from private state into the new client
170+
client.SetApiToken(privateEphemeralApiToken.Token)
171+
172+
// log out the client using the swapped-in token
173+
err = client.Logout(ctx)
174+
if err != nil {
175+
var ace apstra.ClientErr
176+
if errors.As(err, &ace) && ace.Type() == apstra.ErrAuthFail {
177+
return // 401 is okay
178+
}
179+
180+
resp.Diagnostics.AddError("Error while logging out the API key", err.Error())
181+
return
182+
}
183+
}
184+
185+
func (o *ephemeralToken) setClient(client *apstra.Client) {
186+
o.client = client
187+
}

0 commit comments

Comments
 (0)