diff --git a/resource_token.go b/resource_token.go index 2a0c498..1fdb89d 100644 --- a/resource_token.go +++ b/resource_token.go @@ -15,12 +15,14 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) +// ProviderData contains configuration passed from the provider to the resource. type ProviderData struct { AuthHeader string ServerURL string TLSSkipVerify bool } +// BitbucketTokenResource manages Bitbucket repository access tokens. type BitbucketTokenResource struct { authHeader string serverURL string @@ -31,47 +33,61 @@ func NewBitbucketTokenResource() resource.Resource { return &BitbucketTokenResource{} } +// BitbucketTokenResourceModel maps Terraform schema attributes to Go fields. type BitbucketTokenResourceModel struct { - ID types.String `tfsdk:"id"` - TokenName types.String `tfsdk:"token_name"` - ProjectName types.String `tfsdk:"project_name"` - RepositoryName types.String `tfsdk:"repository_name"` - Token types.String `tfsdk:"token"` + ID types.String `tfsdk:"id"` + TokenName types.String `tfsdk:"token_name"` // prefix provided by user + ProjectName types.String `tfsdk:"project_name"` + RepositoryName types.String `tfsdk:"repository_name"` + 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" } -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{ - 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{ "id": schema.StringAttribute{ Computed: true, }, "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 '-'.", Required: true, }, "project_name": schema.StringAttribute{ - Description: "Name of the Bitbucket project.", + Description: "Name/key of the Bitbucket project.", Required: true, }, "repository_name": schema.StringAttribute{ - Description: "Name of the Bitbucket repository.", + Description: "Slug/name of the Bitbucket repository.", Required: true, }, "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, Sensitive: true, }, + "current_token_name": schema.StringAttribute{ + Description: "Identifier of the currently managed token (e.g., '-').", + 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 { return } @@ -80,15 +96,15 @@ func (r *BitbucketTokenResource) Configure(ctx context.Context, req resource.Con if !ok { resp.Diagnostics.AddError( "Unexpected Provider Data Type", - fmt.Sprintf("Expected ProviderData, got: %T", req.ProviderData), + fmt.Sprintf("Expected *ProviderData, got: %T", req.ProviderData), ) return } if providerData.ServerURL == "" { resp.Diagnostics.AddError( - "Invalid provider configuration", - "The 'server_url' in provider configuration cannot be empty.", + "Invalid Provider Configuration", + "The 'server_url' cannot be empty.", ) return } @@ -98,94 +114,217 @@ func (r *BitbucketTokenResource) Configure(ctx context.Context, req resource.Con r.tlsSkipVerify = providerData.TLSSkipVerify } +// httpClient creates a custom HTTP client with optional TLS skip verification. func (r *BitbucketTokenResource) httpClient() *http.Client { tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: r.tlsSkipVerify}, + TLSClientConfig: &tls.Config{InsecureSkipVerify: r.tlsSkipVerify}, // #nosec G402 - intentional per user config } return &http.Client{ - Timeout: 15 * time.Second, + Timeout: 20 * time.Second, Transport: tr, } } -func (r *BitbucketTokenResource) getExistingToken(auth, baseURL, project, repo, name string) (string, error) { - apiURL := fmt.Sprintf("%s/rest/access-tokens/latest/projects/%s/repos/%s?limit=10000", baseURL, project, repo) +// tokenInfo describes an access token returned by listing API. +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() - reqGet, _ := http.NewRequest("GET", apiURL, nil) - reqGet.Header.Add("Authorization", "Basic "+auth) - respGet, err := client.Do(reqGet) + req, _ := http.NewRequest("GET", apiURL, nil) + req.Header.Add("Authorization", "Basic "+auth) + + resp, err := client.Do(req) if err != nil { - return "", err + return nil, err } - defer respGet.Body.Close() + defer resp.Body.Close() - body, _ := io.ReadAll(respGet.Body) - var respJSON map[string]interface{} - _ = json.Unmarshal(body, &respJSON) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("Bitbucket API returned %d: %s", resp.StatusCode, string(body)) + } - values, _ := respJSON["values"].([]interface{}) - now := time.Now().UnixMilli() - var latestExpiry int64 - var latestToken string + body, _ := io.ReadAll(resp.Body) + var jsonResp map[string]interface{} + _ = json.Unmarshal(body, &jsonResp) + values, _ := jsonResp["values"].([]interface{}) + var out []tokenInfo for _, v := range values { obj, ok := v.(map[string]interface{}) if !ok { continue } - n, _ := obj["name"].(string) - eFloat, _ := obj["expiryDate"].(float64) - e := int64(eFloat) * 1000 - if len(n) >= len(name) && n[:len(name)] == name && e > now && e > latestExpiry { - latestExpiry = e - latestToken = n + name, _ := obj["name"].(string) + if len(name) < len(prefix) || name[:len(prefix)] != prefix { + continue } - } + exp, _ := obj["expiryDate"].(float64) // ms since epoch + expMs := int64(exp) - if latestToken == "" { - return "", nil + var perms []string + 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) { - now := time.Now().UnixMilli() +// getTokenByName searches list results for an exact name. +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 = © + } + } + 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) payload := map[string]interface{}{ "expiryDays": 90, - "name": fmt.Sprintf("%s-%d", name, now), + "name": fmt.Sprintf("%s-%d", prefix, time.Now().UnixMilli()), "permissions": []string{"REPO_READ"}, } - - payloadBytes, _ := json.Marshal(payload) + bodyBytes, _ := json.Marshal(payload) 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)) - reqPut.Header.Add("Authorization", "Basic "+auth) - reqPut.Header.Add("Content-Type", "application/json") - - respPut, err := client.Do(reqPut) + resp, err := client.Do(req) if err != nil { - return "", err + return "", "", 0, err } - defer respPut.Body.Close() + defer resp.Body.Close() - bodyPut, _ := io.ReadAll(respPut.Body) - var putJSON map[string]interface{} - _ = json.Unmarshal(bodyPut, &putJSON) - - tok, _ := putJSON["token"].(string) - if tok == "" { - return "", fmt.Errorf("failed to obtain token from API response: %s", string(bodyPut)) + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return "", "", 0, fmt.Errorf("Bitbucket API returned %d: %s", resp.StatusCode, string(body)) } - 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) { var data BitbucketTokenResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) @@ -193,44 +332,18 @@ func (r *BitbucketTokenResource) Create(ctx context.Context, req resource.Create return } - existing, err := r.getExistingToken( - r.authHeader, - r.serverURL, - data.ProjectName.ValueString(), - data.RepositoryName.ValueString(), - data.TokenName.ValueString(), - ) + out, err := r.ensureToken(&data) if err != nil { - resp.Diagnostics.AddError("Error checking existing token", err.Error()) + resp.Diagnostics.AddError("Error ensuring token", err.Error()) return } - if existing != "" { - data.Token = types.StringValue(existing) - } 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)...) + out.ID = types.StringValue(fmt.Sprintf("%s/%s/%s", out.ProjectName.ValueString(), out.RepositoryName.ValueString(), out.TokenName.ValueString())) + resp.Diagnostics.Append(resp.State.Set(ctx, out)...) } +// 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) { var data BitbucketTokenResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) @@ -238,19 +351,30 @@ func (r *BitbucketTokenResource) Read(ctx context.Context, req resource.ReadRequ return } - existing, err := r.getExistingToken( - r.authHeader, - r.serverURL, - data.ProjectName.ValueString(), - data.RepositoryName.ValueString(), - data.TokenName.ValueString(), - ) + 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 { - resp.Diagnostics.AddError("Error reading token", err.Error()) + resp.Diagnostics.AddError("Error listing tokens", err.Error()) 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) return } @@ -258,45 +382,41 @@ func (r *BitbucketTokenResource) Read(ctx context.Context, req resource.ReadRequ 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) { - var data BitbucketTokenResourceModel - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + var plan BitbucketTokenResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } - existing, err := r.getExistingToken( - r.authHeader, - r.serverURL, - data.ProjectName.ValueString(), - data.RepositoryName.ValueString(), - data.TokenName.ValueString(), - ) + // Carry over prior state (for secret) if present. + var state BitbucketTokenResourceModel + _ = req.State.Get(ctx, &state) + + // Start from plan but keep any state-held secret/name/expiry for reuse. + 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 { - resp.Diagnostics.AddError("Error checking existing token", err.Error()) + resp.Diagnostics.AddError("Error ensuring token on update", err.Error()) return } - if existing != "" { - data.Token = types.StringValue(existing) - } 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)...) + out.ID = types.StringValue(fmt.Sprintf("%s/%s/%s", out.ProjectName.ValueString(), out.RepositoryName.ValueString(), out.TokenName.ValueString())) + resp.Diagnostics.Append(resp.State.Set(ctx, out)...) } +// Delete removes the tracked token if it still exists. func (r *BitbucketTokenResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var data BitbucketTokenResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) @@ -304,34 +424,13 @@ func (r *BitbucketTokenResource) Delete(ctx context.Context, req resource.Delete return } - auth := r.authHeader project := data.ProjectName.ValueString() repo := data.RepositoryName.ValueString() - name := data.TokenName.ValueString() - baseURL := r.serverURL + name := data.CurrentTokenName.ValueString() - client := r.httpClient() - - 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 { + if name != "" { + if err := r.deleteToken(r.authHeader, r.serverURL, project, repo, name); err != nil { 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)), - ) - } } }