-
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.
- Loading branch information
1 parent
4513764
commit 88b3c8a
Showing
9 changed files
with
453 additions
and
39 deletions.
There are no files selected for viewing
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,99 @@ | ||
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:"-"` | ||
} | ||
|
||
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)}, | ||
}, | ||
} | ||
} | ||
|
||
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 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 unpacking token claims", 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) { | ||
var privateEphemeralApiToken = private.EphemeralApiToken{ | ||
Token: o.Value.ValueString(), | ||
ExpiresAt: o.ExpiresAt, | ||
WarnThreshold: time.Duration(o.WarnSeconds.ValueInt64()) * time.Second, | ||
} | ||
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,51 @@ | ||
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,174 @@ | ||
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.EphemeralResourceWithRenew = (*ephemeralToken)(nil) | ||
_ ephemeral.EphemeralResourceWithConfigure = (*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: "This Ephemeral Resource retrieves a unique API token and invalidates it on exit.", | ||
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 | ||
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 | ||
} | ||
|
||
config.SetPrivateState(ctx, resp.Private, &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), | ||
) | ||
} | ||
|
||
// set the refresh timestamp | ||
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), | ||
) | ||
} | ||
|
||
resp.RenewAt = privateEphemeralApiToken.ExpiresAt | ||
} | ||
|
||
func (o *ephemeralToken) Close(ctx context.Context, req ephemeral.CloseRequest, resp *ephemeral.CloseResponse) { | ||
// 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 | ||
} | ||
|
||
// extract the private state data | ||
var privateEphemeralApiToken private.EphemeralApiToken | ||
privateEphemeralApiToken.LoadPrivateState(ctx, req.Private, &resp.Diagnostics) | ||
|
||
// swap the API token from private state into the new client | ||
client.SetApiToken(privateEphemeralApiToken.Token) | ||
|
||
// log out the client | ||
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 | ||
} |
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,45 @@ | ||
package private | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"time" | ||
|
||
"github.com/hashicorp/terraform-plugin-framework/diag" | ||
) | ||
|
||
type EphemeralApiToken struct { | ||
Token string `json:"token"` | ||
ExpiresAt time.Time `json:"expires_at"` | ||
WarnThreshold time.Duration `json:"warn_threshold"` | ||
} | ||
|
||
func (o *EphemeralApiToken) LoadApiData(_ context.Context, token string, expiresAt time.Time, warnThreshold time.Duration, diags *diag.Diagnostics) { | ||
o.Token = token | ||
o.ExpiresAt = expiresAt | ||
o.WarnThreshold = warnThreshold | ||
} | ||
|
||
func (o *EphemeralApiToken) LoadPrivateState(ctx context.Context, ps State, diags *diag.Diagnostics) { | ||
b, d := ps.GetKey(ctx, "EphemeralApiToken") | ||
diags.Append(d...) | ||
if diags.HasError() { | ||
return | ||
} | ||
|
||
err := json.Unmarshal(b, &o) | ||
if err != nil { | ||
diags.AddError("failed to unmarshal private state", err.Error()) | ||
return | ||
} | ||
} | ||
|
||
func (o *EphemeralApiToken) SetPrivateState(ctx context.Context, ps State, diags *diag.Diagnostics) { | ||
b, err := json.Marshal(o) | ||
if err != nil { | ||
diags.AddError("failed to marshal private state", err.Error()) | ||
return | ||
} | ||
|
||
diags.Append(ps.SetKey(ctx, "EphemeralApiToken", b)...) | ||
} |
Oops, something went wrong.