-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #996 from Juniper/ephemeral-api-token
Introduce API Token Ephemeral Resource
- Loading branch information
Showing
27 changed files
with
1,478 additions
and
233 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
365 changes: 365 additions & 0 deletions
365
Third_Party_Code/github.com/hashicorp/go-retryablehttp/LICENSE
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
package authentication | ||
|
||
import ( | ||
"context" | ||
"encoding/base64" | ||
"encoding/json" | ||
"fmt" | ||
"strings" | ||
"time" | ||
|
||
"github.com/Juniper/terraform-provider-apstra/apstra/private" | ||
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator" | ||
"github.com/hashicorp/terraform-plugin-framework/diag" | ||
ephemeralSchema "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" | ||
"github.com/hashicorp/terraform-plugin-framework/schema/validator" | ||
"github.com/hashicorp/terraform-plugin-framework/types" | ||
) | ||
|
||
const apiTokenDefaultWarning = 60 | ||
|
||
type ApiToken struct { | ||
Value types.String `tfsdk:"value"` | ||
SessionId types.String `tfsdk:"session_id"` | ||
UserName types.String `tfsdk:"user_name"` | ||
WarnSeconds types.Int64 `tfsdk:"warn_seconds"` | ||
ExpiresAt time.Time `tfsdk:"-"` | ||
DoNotLogOut types.Bool `tfsdk:"do_not_log_out"` | ||
} | ||
|
||
func (o ApiToken) EphemeralAttributes() map[string]ephemeralSchema.Attribute { | ||
return map[string]ephemeralSchema.Attribute{ | ||
"value": ephemeralSchema.StringAttribute{ | ||
Computed: true, | ||
MarkdownDescription: "The API token value.", | ||
}, | ||
"session_id": ephemeralSchema.StringAttribute{ | ||
Computed: true, | ||
MarkdownDescription: "The API session ID associated with the token.", | ||
}, | ||
"user_name": ephemeralSchema.StringAttribute{ | ||
Computed: true, | ||
MarkdownDescription: "The user name associated with the session ID.", | ||
}, | ||
"warn_seconds": ephemeralSchema.Int64Attribute{ | ||
Optional: true, | ||
Computed: true, | ||
MarkdownDescription: fmt.Sprintf("Terraform will produce a warning when the token value is "+ | ||
"referenced with less than this amount of time remaining before expiration. Note that "+ | ||
"determination of remaining token lifetime depends on clock sync between the Apstra server and "+ | ||
"the Terraform host. Value `0` disables warnings. Default value is `%d`.", apiTokenDefaultWarning), | ||
Validators: []validator.Int64{int64validator.AtLeast(0)}, | ||
}, | ||
"do_not_log_out": ephemeralSchema.BoolAttribute{ | ||
Optional: true, | ||
MarkdownDescription: "By default, API sessions are closed when Terraform's `Close` operation calls " + | ||
"`logout`. Set this value to `true` to prevent ending the session when Terraform determines the " + | ||
"API key is no longer in use.", | ||
}, | ||
} | ||
} | ||
|
||
func (o *ApiToken) LoadApiData(_ context.Context, in string, diags *diag.Diagnostics) { | ||
parts := strings.Split(in, ".") | ||
if len(parts) != 3 { | ||
diags.AddError("unexpected API response", fmt.Sprintf("JWT should have 3 parts, got %d", len(parts))) | ||
return | ||
} | ||
|
||
claimsB64 := parts[1] + strings.Repeat("=", (4-len(parts[1])%4)%4) // pad the b64 part as necessary | ||
claimsBytes, err := base64.StdEncoding.DecodeString(claimsB64) | ||
if err != nil { | ||
diags.AddError("failed base64 decoding token claims", err.Error()) | ||
return | ||
} | ||
|
||
var claims struct { | ||
Username string `json:"username"` | ||
UserSession string `json:"user_session"` | ||
Expiration int64 `json:"exp"` | ||
} | ||
err = json.Unmarshal(claimsBytes, &claims) | ||
if err != nil { | ||
diags.AddError("failed unmarshaling token claims JSON payload", err.Error()) | ||
return | ||
} | ||
|
||
o.Value = types.StringValue(in) | ||
o.UserName = types.StringValue(claims.Username) | ||
o.SessionId = types.StringValue(claims.UserSession) | ||
o.ExpiresAt = time.Unix(claims.Expiration, 0) | ||
} | ||
|
||
func (o *ApiToken) SetDefaults() { | ||
if o.WarnSeconds.IsNull() { | ||
o.WarnSeconds = types.Int64Value(apiTokenDefaultWarning) | ||
} | ||
} | ||
|
||
func (o *ApiToken) SetPrivateState(ctx context.Context, ps private.State, diags *diag.Diagnostics) { | ||
privateEphemeralApiToken := private.EphemeralApiToken{ | ||
Token: o.Value.ValueString(), | ||
ExpiresAt: o.ExpiresAt, | ||
WarnThreshold: time.Duration(o.WarnSeconds.ValueInt64()) * time.Second, | ||
DoNotLogOut: o.DoNotLogOut.ValueBool(), | ||
} | ||
privateEphemeralApiToken.SetPrivateState(ctx, ps, diags) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
package tfapstra | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
|
||
"github.com/Juniper/apstra-go-sdk/apstra" | ||
"github.com/hashicorp/terraform-plugin-framework/ephemeral" | ||
) | ||
|
||
type ephemeralWithSetClient interface { | ||
ephemeral.EphemeralResourceWithConfigure | ||
setClient(*apstra.Client) | ||
} | ||
|
||
type ephemeralWithSetDcBpClientFunc interface { | ||
ephemeral.EphemeralResourceWithConfigure | ||
setBpClientFunc(func(context.Context, string) (*apstra.TwoStageL3ClosClient, error)) | ||
} | ||
|
||
type ephemeralWithSetFfBpClientFunc interface { | ||
ephemeral.EphemeralResourceWithConfigure | ||
setBpClientFunc(func(context.Context, string) (*apstra.FreeformClient, error)) | ||
} | ||
|
||
func configureEphemeral(_ context.Context, ep ephemeral.EphemeralResourceWithConfigure, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { | ||
if req.ProviderData == nil { | ||
return // cannot continue | ||
} | ||
|
||
var pd *providerData | ||
var ok bool | ||
|
||
if pd, ok = req.ProviderData.(*providerData); !ok { | ||
resp.Diagnostics.AddError( | ||
errDataSourceConfigureProviderDataSummary, | ||
fmt.Sprintf(errDataSourceConfigureProviderDataDetail, *pd, req.ProviderData), | ||
) | ||
} | ||
|
||
if ep, ok := ep.(ephemeralWithSetClient); ok { | ||
ep.setClient(pd.client) | ||
} | ||
|
||
if ep, ok := ep.(ephemeralWithSetDcBpClientFunc); ok { | ||
ep.setBpClientFunc(pd.getTwoStageL3ClosClient) | ||
} | ||
|
||
if ep, ok := ep.(ephemeralWithSetFfBpClientFunc); ok { | ||
ep.setBpClientFunc(pd.getFreeformClient) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,187 @@ | ||
package tfapstra | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"fmt" | ||
"time" | ||
|
||
"github.com/Juniper/apstra-go-sdk/apstra" | ||
"github.com/Juniper/terraform-provider-apstra/apstra/authentication" | ||
"github.com/Juniper/terraform-provider-apstra/apstra/private" | ||
"github.com/hashicorp/terraform-plugin-framework/ephemeral" | ||
"github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" | ||
) | ||
|
||
var ( | ||
_ ephemeral.EphemeralResource = (*ephemeralToken)(nil) | ||
_ ephemeral.EphemeralResourceWithClose = (*ephemeralToken)(nil) | ||
_ ephemeral.EphemeralResourceWithConfigure = (*ephemeralToken)(nil) | ||
_ ephemeral.EphemeralResourceWithRenew = (*ephemeralToken)(nil) | ||
_ ephemeralWithSetClient = (*ephemeralToken)(nil) | ||
) | ||
|
||
type ephemeralToken struct { | ||
client *apstra.Client | ||
} | ||
|
||
func (o *ephemeralToken) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { | ||
resp.TypeName = req.ProviderTypeName + "_api_token" | ||
} | ||
|
||
func (o *ephemeralToken) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { | ||
resp.Schema = schema.Schema{ | ||
MarkdownDescription: docCategoryAuthentication + "This Ephemeral Resource retrieves a unique API token and (optionally) invalidates it on Close.", | ||
Attributes: authentication.ApiToken{}.EphemeralAttributes(), | ||
} | ||
} | ||
|
||
func (o *ephemeralToken) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { | ||
configureEphemeral(ctx, o, req, resp) | ||
} | ||
|
||
func (o *ephemeralToken) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { | ||
var config authentication.ApiToken | ||
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) | ||
if resp.Diagnostics.HasError() { | ||
return | ||
} | ||
|
||
// set default values | ||
config.SetDefaults() | ||
|
||
// create a new client using the credentials in the embedded client's config | ||
client, err := o.client.Config().NewClient(ctx) | ||
if err != nil { | ||
resp.Diagnostics.AddError("error creating new client", err.Error()) | ||
return | ||
} | ||
|
||
// log in so that the new client fetches an API token | ||
err = client.Login(ctx) | ||
if err != nil { | ||
resp.Diagnostics.AddError("error logging in new client", err.Error()) | ||
return | ||
} | ||
|
||
// extract the token | ||
token := client.GetApiToken() | ||
if token == "" { | ||
resp.Diagnostics.AddError("requested API token is empty", "requested API token is empty") | ||
return | ||
} | ||
|
||
// Destroy the new client without invalidating the API token we just collected. | ||
// We call Logout() here only for the side effect of stopping the task monitor | ||
// goroutine. This client *can't* invalidate the session because it no longer | ||
// has an API token. | ||
client.SetApiToken("") | ||
err = client.Logout(ctx) | ||
if err != nil { | ||
resp.Diagnostics.AddError("error logging out client", err.Error()) | ||
return | ||
} | ||
|
||
config.LoadApiData(ctx, token, &resp.Diagnostics) | ||
if resp.Diagnostics.HasError() { | ||
return | ||
} | ||
|
||
// sanity check the token lifetime | ||
now := time.Now() | ||
if now.After(config.ExpiresAt) { | ||
resp.Diagnostics.AddError( | ||
"Just-fetched API token is expired", | ||
fmt.Sprintf("Token expired at: %s. Current time is: %s", config.ExpiresAt, now), | ||
) | ||
return | ||
} | ||
|
||
// warn the user about imminent expiration | ||
warn := time.Duration(config.WarnSeconds.ValueInt64()) * time.Second | ||
if now.Add(warn).After(config.ExpiresAt) { | ||
resp.Diagnostics.AddWarning( | ||
fmt.Sprintf("API token expires within %d second warning threshold", config.WarnSeconds), | ||
fmt.Sprintf("API token expires at %s. Current time: %s", config.ExpiresAt, now), | ||
) | ||
} | ||
|
||
// save the private state | ||
config.SetPrivateState(ctx, resp.Private, &resp.Diagnostics) | ||
if resp.Diagnostics.HasError() { | ||
return | ||
} | ||
|
||
// set the renew timestamp to the early warning time | ||
resp.RenewAt = config.ExpiresAt.Add(-1 * warn) | ||
|
||
// set the result | ||
resp.Diagnostics.Append(resp.Result.Set(ctx, &config)...) | ||
} | ||
|
||
func (o *ephemeralToken) Renew(ctx context.Context, req ephemeral.RenewRequest, resp *ephemeral.RenewResponse) { | ||
var privateEphemeralApiToken private.EphemeralApiToken | ||
privateEphemeralApiToken.LoadPrivateState(ctx, req.Private, &resp.Diagnostics) | ||
if resp.Diagnostics.HasError() { | ||
return | ||
} | ||
|
||
now := time.Now() | ||
if now.After(privateEphemeralApiToken.ExpiresAt) { | ||
resp.Diagnostics.AddError( | ||
"API token has expired", | ||
fmt.Sprintf("Token expired at: %s. Current time is: %s", privateEphemeralApiToken.ExpiresAt, now), | ||
) | ||
return | ||
} | ||
|
||
if now.Add(privateEphemeralApiToken.WarnThreshold).After(privateEphemeralApiToken.ExpiresAt) { | ||
resp.Diagnostics.AddWarning( | ||
fmt.Sprintf("API token expires within %d second warning threshold", privateEphemeralApiToken.WarnThreshold), | ||
fmt.Sprintf("API token expires at %s. Current time: %s", privateEphemeralApiToken.ExpiresAt, now), | ||
) | ||
} | ||
|
||
// set the renew timestamp to the expiration time | ||
resp.RenewAt = privateEphemeralApiToken.ExpiresAt | ||
} | ||
|
||
func (o *ephemeralToken) Close(ctx context.Context, req ephemeral.CloseRequest, resp *ephemeral.CloseResponse) { | ||
// extract the private state data | ||
var privateEphemeralApiToken private.EphemeralApiToken | ||
privateEphemeralApiToken.LoadPrivateState(ctx, req.Private, &resp.Diagnostics) | ||
|
||
if privateEphemeralApiToken.DoNotLogOut { | ||
return // user doesn't want the token invalidated, so there's nothing to do | ||
} | ||
|
||
if time.Now().After(privateEphemeralApiToken.ExpiresAt) { | ||
return // token has already expired, so there's nothing to do | ||
} | ||
|
||
// create a new client based on the embedded client's config | ||
client, err := o.client.Config().NewClient(ctx) | ||
if err != nil { | ||
resp.Diagnostics.AddError("error creating new client", err.Error()) | ||
return | ||
} | ||
|
||
// copy the API token from private state into the new client | ||
client.SetApiToken(privateEphemeralApiToken.Token) | ||
|
||
// log out the client using the swapped-in token | ||
err = client.Logout(ctx) | ||
if err != nil { | ||
var ace apstra.ClientErr | ||
if errors.As(err, &ace) && ace.Type() == apstra.ErrAuthFail { | ||
return // 401 is okay | ||
} | ||
|
||
resp.Diagnostics.AddError("Error while logging out the API key", err.Error()) | ||
return | ||
} | ||
} | ||
|
||
func (o *ephemeralToken) setClient(client *apstra.Client) { | ||
o.client = client | ||
} |
Oops, something went wrong.