Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce API Token Ephemeral Resource #996

Merged
merged 12 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from 10 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
399 changes: 387 additions & 12 deletions Third_Party_Code/NOTICES.md

Large diffs are not rendered by default.

365 changes: 365 additions & 0 deletions Third_Party_Code/github.com/hashicorp/go-retryablehttp/LICENSE

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions Third_Party_Code/golang.org/x/mod/LICENSE
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (c) 2009 The Go Authors. All rights reserved.
Copyright 2009 The Go Authors.

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

Expand Down
107 changes: 107 additions & 0 deletions apstra/authentication/token.go
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)
}
2 changes: 1 addition & 1 deletion apstra/configure_data_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tfapstra
import (
"context"
"fmt"

"github.com/Juniper/apstra-go-sdk/apstra"
"github.com/hashicorp/terraform-plugin-framework/datasource"
)
Expand Down Expand Up @@ -48,5 +49,4 @@ func configureDataSource(_ context.Context, ds datasource.DataSourceWithConfigur
if ds, ok := ds.(datasourceWithSetFfBpClientFunc); ok {
ds.setBpClientFunc(pd.getFreeformClient)
}

}
52 changes: 52 additions & 0 deletions apstra/configure_ephemeral.go
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)
}
}
15 changes: 8 additions & 7 deletions apstra/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ const (
errBpClientCreateSummary = "Failed to create client for Blueprint %s"
errBpNotFoundSummary = "Blueprint %s not found"

docCategorySeparator = " --- "
docCategoryDesign = "Design" + docCategorySeparator
docCategoryResources = "Resource Pools" + docCategorySeparator
docCategoryDatacenter = "Reference Design: Datacenter" + docCategorySeparator
docCategoryFreeform = "Reference Design: Freeform" + docCategorySeparator
docCategoryRefDesignAny = "Reference Design: Shared" + docCategorySeparator
docCategoryDevices = "Devices" + docCategorySeparator
docCategorySeparator = " --- "
docCategoryAuthentication = "Authentication" + docCategorySeparator
docCategoryDatacenter = "Reference Design: Datacenter" + docCategorySeparator
docCategoryDesign = "Design" + docCategorySeparator
docCategoryDevices = "Devices" + docCategorySeparator
docCategoryFreeform = "Reference Design: Freeform" + docCategorySeparator
docCategoryRefDesignAny = "Reference Design: Shared" + docCategorySeparator
docCategoryResources = "Resource Pools" + docCategorySeparator
)
185 changes: 185 additions & 0 deletions apstra/ephemeral_api_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
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 use Logout() here because it stops the task monitor goroutine.
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
}
Loading
Loading