fix token exact handling

This commit is contained in:
Jan Husak
2025-11-12 12:01:17 +01:00
parent cffc2656b0
commit 84725d51cf

View File

@@ -15,12 +15,14 @@ import (
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
) )
// ProviderData contains configuration passed from the provider to the resource.
type ProviderData struct { type ProviderData struct {
AuthHeader string AuthHeader string
ServerURL string ServerURL string
TLSSkipVerify bool TLSSkipVerify bool
} }
// BitbucketTokenResource manages Bitbucket repository access tokens.
type BitbucketTokenResource struct { type BitbucketTokenResource struct {
authHeader string authHeader string
serverURL string serverURL string
@@ -31,47 +33,61 @@ func NewBitbucketTokenResource() resource.Resource {
return &BitbucketTokenResource{} return &BitbucketTokenResource{}
} }
// BitbucketTokenResourceModel maps Terraform schema attributes to Go fields.
type BitbucketTokenResourceModel struct { type BitbucketTokenResourceModel struct {
ID types.String `tfsdk:"id"` ID types.String `tfsdk:"id"`
TokenName types.String `tfsdk:"token_name"` TokenName types.String `tfsdk:"token_name"` // prefix provided by user
ProjectName types.String `tfsdk:"project_name"` ProjectName types.String `tfsdk:"project_name"`
RepositoryName types.String `tfsdk:"repository_name"` RepositoryName types.String `tfsdk:"repository_name"`
Token types.String `tfsdk:"token"` Token types.String `tfsdk:"token"` // secret; returned only on creation; preserved from state
CurrentTokenName types.String `tfsdk:"current_token_name"` // actual token identifier (prefix-epoch)
CurrentTokenExpiry types.Int64 `tfsdk:"current_token_expiry"` // ms since epoch
} }
func (r *BitbucketTokenResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { // Metadata defines the Terraform resource type name.
func (r *BitbucketTokenResource) Metadata(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = "bitbucket_token" resp.TypeName = "bitbucket_token"
} }
func (r *BitbucketTokenResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { // Schema defines the Terraform resource schema.
func (r *BitbucketTokenResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{ resp.Schema = schema.Schema{
Description: "Manages Bitbucket access tokens for a repository.", Description: "Manages Bitbucket access tokens for a repository. The token secret is only returned when created and is preserved in state for reuse while valid.",
Attributes: map[string]schema.Attribute{ Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{ "id": schema.StringAttribute{
Computed: true, Computed: true,
}, },
"token_name": schema.StringAttribute{ "token_name": schema.StringAttribute{
Description: "Name prefix for the Bitbucket access token.", Description: "Name prefix for the Bitbucket access token. Actual token will be created as '<prefix>-<epoch_ms>'.",
Required: true, Required: true,
}, },
"project_name": schema.StringAttribute{ "project_name": schema.StringAttribute{
Description: "Name of the Bitbucket project.", Description: "Name/key of the Bitbucket project.",
Required: true, Required: true,
}, },
"repository_name": schema.StringAttribute{ "repository_name": schema.StringAttribute{
Description: "Name of the Bitbucket repository.", Description: "Slug/name of the Bitbucket repository.",
Required: true, Required: true,
}, },
"token": schema.StringAttribute{ "token": schema.StringAttribute{
Description: "Generated Bitbucket access token (sensitive).", Description: "Bitbucket access token secret (only returned on creation; preserved from state if still valid).",
Computed: true, Computed: true,
Sensitive: true, Sensitive: true,
}, },
"current_token_name": schema.StringAttribute{
Description: "Identifier of the currently managed token (e.g., '<prefix>-<epoch_ms>').",
Computed: true,
},
"current_token_expiry": schema.Int64Attribute{
Description: "Expiry of the current token in milliseconds since epoch.",
Computed: true,
},
}, },
} }
} }
func (r *BitbucketTokenResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { // Configure sets up provider-level data for the resource.
func (r *BitbucketTokenResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil { if req.ProviderData == nil {
return return
} }
@@ -80,15 +96,15 @@ func (r *BitbucketTokenResource) Configure(ctx context.Context, req resource.Con
if !ok { if !ok {
resp.Diagnostics.AddError( resp.Diagnostics.AddError(
"Unexpected Provider Data Type", "Unexpected Provider Data Type",
fmt.Sprintf("Expected ProviderData, got: %T", req.ProviderData), fmt.Sprintf("Expected *ProviderData, got: %T", req.ProviderData),
) )
return return
} }
if providerData.ServerURL == "" { if providerData.ServerURL == "" {
resp.Diagnostics.AddError( resp.Diagnostics.AddError(
"Invalid provider configuration", "Invalid Provider Configuration",
"The 'server_url' in provider configuration cannot be empty.", "The 'server_url' cannot be empty.",
) )
return return
} }
@@ -98,94 +114,217 @@ func (r *BitbucketTokenResource) Configure(ctx context.Context, req resource.Con
r.tlsSkipVerify = providerData.TLSSkipVerify r.tlsSkipVerify = providerData.TLSSkipVerify
} }
// httpClient creates a custom HTTP client with optional TLS skip verification.
func (r *BitbucketTokenResource) httpClient() *http.Client { func (r *BitbucketTokenResource) httpClient() *http.Client {
tr := &http.Transport{ tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: r.tlsSkipVerify}, TLSClientConfig: &tls.Config{InsecureSkipVerify: r.tlsSkipVerify}, // #nosec G402 - intentional per user config
} }
return &http.Client{ return &http.Client{
Timeout: 15 * time.Second, Timeout: 20 * time.Second,
Transport: tr, Transport: tr,
} }
} }
func (r *BitbucketTokenResource) getExistingToken(auth, baseURL, project, repo, name string) (string, error) { // tokenInfo describes an access token returned by listing API.
apiURL := fmt.Sprintf("%s/rest/access-tokens/latest/projects/%s/repos/%s?limit=10000", baseURL, project, repo) type tokenInfo struct {
Name string
ExpiryMs int64
Permissions []string
}
// listTokens lists all tokens for a repo and filters by prefix; returns all matches.
func (r *BitbucketTokenResource) listTokens(auth, baseURL, project, repo, prefix string) ([]tokenInfo, error) {
apiURL := fmt.Sprintf("%s/rest/access-tokens/latest/projects/%s/repos/%s?limit=10000", baseURL, project, repo)
client := r.httpClient() client := r.httpClient()
reqGet, _ := http.NewRequest("GET", apiURL, nil) req, _ := http.NewRequest("GET", apiURL, nil)
reqGet.Header.Add("Authorization", "Basic "+auth) req.Header.Add("Authorization", "Basic "+auth)
respGet, err := client.Do(reqGet)
resp, err := client.Do(req)
if err != nil { if err != nil {
return "", err return nil, err
} }
defer respGet.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(respGet.Body) if resp.StatusCode != http.StatusOK {
var respJSON map[string]interface{} body, _ := io.ReadAll(resp.Body)
_ = json.Unmarshal(body, &respJSON) return nil, fmt.Errorf("Bitbucket API returned %d: %s", resp.StatusCode, string(body))
}
values, _ := respJSON["values"].([]interface{}) body, _ := io.ReadAll(resp.Body)
now := time.Now().UnixMilli() var jsonResp map[string]interface{}
var latestExpiry int64 _ = json.Unmarshal(body, &jsonResp)
var latestToken string
values, _ := jsonResp["values"].([]interface{})
var out []tokenInfo
for _, v := range values { for _, v := range values {
obj, ok := v.(map[string]interface{}) obj, ok := v.(map[string]interface{})
if !ok { if !ok {
continue continue
} }
n, _ := obj["name"].(string) name, _ := obj["name"].(string)
eFloat, _ := obj["expiryDate"].(float64) if len(name) < len(prefix) || name[:len(prefix)] != prefix {
e := int64(eFloat) * 1000 continue
if len(n) >= len(name) && n[:len(name)] == name && e > now && e > latestExpiry {
latestExpiry = e
latestToken = n
} }
} exp, _ := obj["expiryDate"].(float64) // ms since epoch
expMs := int64(exp)
if latestToken == "" { var perms []string
return "", nil if ps, ok := obj["permissions"].([]interface{}); ok {
for _, p := range ps {
if s, ok := p.(string); ok {
perms = append(perms, s)
}
}
}
out = append(out, tokenInfo{
Name: name,
ExpiryMs: expMs,
Permissions: perms,
})
} }
return latestToken, nil return out, nil
} }
func (r *BitbucketTokenResource) createToken(auth, baseURL, project, repo, name string) (string, error) { // getTokenByName searches list results for an exact name.
now := time.Now().UnixMilli() func getTokenByName(tokens []tokenInfo, name string) *tokenInfo {
for i := range tokens {
if tokens[i].Name == name {
return &tokens[i]
}
}
return nil
}
// getLatestByExpiry picks the token with the highest expiry.
func getLatestByExpiry(tokens []tokenInfo) *tokenInfo {
var latest *tokenInfo
for i := range tokens {
if latest == nil || tokens[i].ExpiryMs > latest.ExpiryMs {
copy := tokens[i]
latest = &copy
}
}
return latest
}
// createToken creates a new access token and returns (secret, name, expiryMs).
func (r *BitbucketTokenResource) createToken(auth, baseURL, project, repo, prefix string) (string, string, int64, error) {
putURL := fmt.Sprintf("%s/rest/access-tokens/latest/projects/%s/repos/%s", baseURL, project, repo) putURL := fmt.Sprintf("%s/rest/access-tokens/latest/projects/%s/repos/%s", baseURL, project, repo)
payload := map[string]interface{}{ payload := map[string]interface{}{
"expiryDays": 90, "expiryDays": 90,
"name": fmt.Sprintf("%s-%d", name, now), "name": fmt.Sprintf("%s-%d", prefix, time.Now().UnixMilli()),
"permissions": []string{"REPO_READ"}, "permissions": []string{"REPO_READ"},
} }
bodyBytes, _ := json.Marshal(payload)
payloadBytes, _ := json.Marshal(payload)
client := r.httpClient() client := r.httpClient()
req, _ := http.NewRequest("PUT", putURL, bytes.NewReader(bodyBytes))
req.Header.Add("Authorization", "Basic "+auth)
req.Header.Add("Content-Type", "application/json")
reqPut, _ := http.NewRequest("PUT", putURL, bytes.NewReader(payloadBytes)) resp, err := client.Do(req)
reqPut.Header.Add("Authorization", "Basic "+auth)
reqPut.Header.Add("Content-Type", "application/json")
respPut, err := client.Do(reqPut)
if err != nil { if err != nil {
return "", err return "", "", 0, err
} }
defer respPut.Body.Close() defer resp.Body.Close()
bodyPut, _ := io.ReadAll(respPut.Body) if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
var putJSON map[string]interface{} body, _ := io.ReadAll(resp.Body)
_ = json.Unmarshal(bodyPut, &putJSON) return "", "", 0, fmt.Errorf("Bitbucket API returned %d: %s", resp.StatusCode, string(body))
tok, _ := putJSON["token"].(string)
if tok == "" {
return "", fmt.Errorf("failed to obtain token from API response: %s", string(bodyPut))
} }
return tok, nil body, _ := io.ReadAll(resp.Body)
var jsonResp map[string]interface{}
_ = json.Unmarshal(body, &jsonResp)
secret, _ := jsonResp["token"].(string)
name, _ := jsonResp["name"].(string)
exp, _ := jsonResp["expiryDate"].(float64)
expMs := int64(exp)
if secret == "" || name == "" || expMs == 0 {
return "", "", 0, fmt.Errorf("API response missing fields (token/name/expiryDate): %s", string(body))
}
return secret, name, expMs, nil
} }
// Create resource // deleteToken removes a token by name.
func (r *BitbucketTokenResource) deleteToken(auth, baseURL, project, repo, name string) error {
client := r.httpClient()
delURL := fmt.Sprintf("%s/rest/access-tokens/latest/projects/%s/repos/%s/%s", baseURL, project, repo, name)
req, _ := http.NewRequest("DELETE", delURL, nil)
req.Header.Add("Authorization", "Basic "+auth)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Bitbucket returned %d: %s", resp.StatusCode, string(body))
}
return nil
}
// ensureToken ensures we end with a valid token secret in state.
// If state has a valid token → keep its secret.
// If missing/expired → delete expired (if any) and create a fresh one.
func (r *BitbucketTokenResource) ensureToken(data *BitbucketTokenResourceModel) (*BitbucketTokenResourceModel, error) {
project := data.ProjectName.ValueString()
repo := data.RepositoryName.ValueString()
prefix := data.TokenName.ValueString()
tokens, err := r.listTokens(r.authHeader, r.serverURL, project, repo, prefix)
if err != nil {
return nil, err
}
nowMs := time.Now().UnixMilli()
// If we already have a specific token tracked in state, check it first.
stateName := data.CurrentTokenName.ValueString()
stateSecret := data.Token.ValueString()
if stateName != "" && stateSecret != "" {
if t := getTokenByName(tokens, stateName); t != nil && t.ExpiryMs > nowMs {
// Still valid → reuse secret from state.
data.Token = types.StringValue(stateSecret)
data.CurrentTokenName = types.StringValue(t.Name)
data.CurrentTokenExpiry = types.Int64Value(t.ExpiryMs)
return data, nil
}
// If expired or missing → try to delete it (best effort).
if t := getTokenByName(tokens, stateName); t != nil && t.ExpiryMs <= nowMs {
_ = r.deleteToken(r.authHeader, r.serverURL, project, repo, stateName)
}
}
// Clean up all expired tokens with this prefix before creating a new one.
for _, t := range tokens {
if t.ExpiryMs <= nowMs {
_ = r.deleteToken(r.authHeader, r.serverURL, project, repo, t.Name)
}
}
// Create a new token and record its secret + metadata.
secret, newName, expiry, err := r.createToken(r.authHeader, r.serverURL, project, repo, prefix)
if err != nil {
return nil, err
}
data.Token = types.StringValue(secret)
data.CurrentTokenName = types.StringValue(newName)
data.CurrentTokenExpiry = types.Int64Value(expiry)
return data, nil
}
// Create — always produces a token value. Since no prior state exists,
// we create a fresh token (after cleaning up any expired ones for the prefix).
func (r *BitbucketTokenResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { func (r *BitbucketTokenResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data BitbucketTokenResourceModel var data BitbucketTokenResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
@@ -193,44 +332,18 @@ func (r *BitbucketTokenResource) Create(ctx context.Context, req resource.Create
return return
} }
existing, err := r.getExistingToken( out, err := r.ensureToken(&data)
r.authHeader,
r.serverURL,
data.ProjectName.ValueString(),
data.RepositoryName.ValueString(),
data.TokenName.ValueString(),
)
if err != nil { if err != nil {
resp.Diagnostics.AddError("Error checking existing token", err.Error()) resp.Diagnostics.AddError("Error ensuring token", err.Error())
return return
} }
if existing != "" { out.ID = types.StringValue(fmt.Sprintf("%s/%s/%s", out.ProjectName.ValueString(), out.RepositoryName.ValueString(), out.TokenName.ValueString()))
data.Token = types.StringValue(existing) resp.Diagnostics.Append(resp.State.Set(ctx, out)...)
} else {
token, err := r.createToken(
r.authHeader,
r.serverURL,
data.ProjectName.ValueString(),
data.RepositoryName.ValueString(),
data.TokenName.ValueString(),
)
if err != nil {
resp.Diagnostics.AddError("Error creating new token", err.Error())
return
}
data.Token = types.StringValue(token)
}
data.ID = types.StringValue(fmt.Sprintf("%s/%s/%s",
data.ProjectName.ValueString(),
data.RepositoryName.ValueString(),
data.TokenName.ValueString(),
))
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
} }
// Read — does not create new tokens (to keep Read side-effect free).
// If the tracked token is missing or expired, remove from state so the next Apply will recreate it.
func (r *BitbucketTokenResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { func (r *BitbucketTokenResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data BitbucketTokenResourceModel var data BitbucketTokenResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...) resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
@@ -238,19 +351,30 @@ func (r *BitbucketTokenResource) Read(ctx context.Context, req resource.ReadRequ
return return
} }
existing, err := r.getExistingToken( project := data.ProjectName.ValueString()
r.authHeader, repo := data.RepositoryName.ValueString()
r.serverURL, prefix := data.TokenName.ValueString()
data.ProjectName.ValueString(),
data.RepositoryName.ValueString(), tokens, err := r.listTokens(r.authHeader, r.serverURL, project, repo, prefix)
data.TokenName.ValueString(),
)
if err != nil { if err != nil {
resp.Diagnostics.AddError("Error reading token", err.Error()) resp.Diagnostics.AddError("Error listing tokens", err.Error())
return return
} }
if existing == "" { nowMs := time.Now().UnixMilli()
stateName := data.CurrentTokenName.ValueString()
var valid bool
if stateName != "" {
if t := getTokenByName(tokens, stateName); t != nil && t.ExpiryMs > nowMs {
// keep state as-is
data.CurrentTokenExpiry = types.Int64Value(t.ExpiryMs)
valid = true
}
}
if !valid {
// Not present or expired -> remove from state; next Apply will create a new one in Create/Update paths.
resp.State.RemoveResource(ctx) resp.State.RemoveResource(ctx)
return return
} }
@@ -258,45 +382,41 @@ func (r *BitbucketTokenResource) Read(ctx context.Context, req resource.ReadRequ
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
} }
// Update — same semantics as Create: ensure we output a valid token value.
// If state has a valid token, reuse its secret; otherwise delete expired and create new.
func (r *BitbucketTokenResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { func (r *BitbucketTokenResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var data BitbucketTokenResourceModel var plan BitbucketTokenResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() { if resp.Diagnostics.HasError() {
return return
} }
existing, err := r.getExistingToken( // Carry over prior state (for secret) if present.
r.authHeader, var state BitbucketTokenResourceModel
r.serverURL, _ = req.State.Get(ctx, &state)
data.ProjectName.ValueString(),
data.RepositoryName.ValueString(), // Start from plan but keep any state-held secret/name/expiry for reuse.
data.TokenName.ValueString(), if !state.Token.IsNull() && !state.Token.IsUnknown() {
) plan.Token = state.Token
}
if !state.CurrentTokenName.IsNull() && !state.CurrentTokenName.IsUnknown() {
plan.CurrentTokenName = state.CurrentTokenName
}
if !state.CurrentTokenExpiry.IsNull() && !state.CurrentTokenExpiry.IsUnknown() {
plan.CurrentTokenExpiry = state.CurrentTokenExpiry
}
out, err := r.ensureToken(&plan)
if err != nil { if err != nil {
resp.Diagnostics.AddError("Error checking existing token", err.Error()) resp.Diagnostics.AddError("Error ensuring token on update", err.Error())
return return
} }
if existing != "" { out.ID = types.StringValue(fmt.Sprintf("%s/%s/%s", out.ProjectName.ValueString(), out.RepositoryName.ValueString(), out.TokenName.ValueString()))
data.Token = types.StringValue(existing) resp.Diagnostics.Append(resp.State.Set(ctx, out)...)
} else {
token, err := r.createToken(
r.authHeader,
r.serverURL,
data.ProjectName.ValueString(),
data.RepositoryName.ValueString(),
data.TokenName.ValueString(),
)
if err != nil {
resp.Diagnostics.AddError("Error creating new token", err.Error())
return
}
data.Token = types.StringValue(token)
}
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
} }
// Delete removes the tracked token if it still exists.
func (r *BitbucketTokenResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { func (r *BitbucketTokenResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data BitbucketTokenResourceModel var data BitbucketTokenResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...) resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
@@ -304,34 +424,13 @@ func (r *BitbucketTokenResource) Delete(ctx context.Context, req resource.Delete
return return
} }
auth := r.authHeader
project := data.ProjectName.ValueString() project := data.ProjectName.ValueString()
repo := data.RepositoryName.ValueString() repo := data.RepositoryName.ValueString()
name := data.TokenName.ValueString() name := data.CurrentTokenName.ValueString()
baseURL := r.serverURL
client := r.httpClient() if name != "" {
if err := r.deleteToken(r.authHeader, r.serverURL, project, repo, name); err != nil {
tokenID, err := r.getExistingToken(auth, baseURL, project, repo, name)
if err != nil {
resp.Diagnostics.AddWarning("Failed to verify token before deletion", err.Error())
} else if tokenID != "" {
apiURL := fmt.Sprintf("%s/rest/access-tokens/latest/projects/%s/repos/%s/%s", baseURL, project, repo, tokenID)
reqDel, _ := http.NewRequest("DELETE", apiURL, nil)
reqDel.Header.Add("Authorization", "Basic "+auth)
respDel, err := client.Do(reqDel)
if err != nil {
resp.Diagnostics.AddWarning("Error deleting token", err.Error()) resp.Diagnostics.AddWarning("Error deleting token", err.Error())
} else {
defer respDel.Body.Close()
if respDel.StatusCode >= 400 {
body, _ := io.ReadAll(respDel.Body)
resp.Diagnostics.AddWarning(
"Bitbucket returned error during delete",
fmt.Sprintf("Status: %s\nBody: %s", respDel.Status, string(body)),
)
}
} }
} }