| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409 |
- /*
- Copyright © The ESO Authors
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
- https://www.apache.org/licenses/LICENSE-2.0
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
- */
- // Package httpclient provides an HTTP client for interacting with BeyondTrust Workload Credentials API.
- // API Documentation: https://docs.beyondtrust.com/bt-docs/docs/secrets-api
- package httpclient
- import (
- "context"
- "crypto/tls"
- "crypto/x509"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
- "time"
- btwcutil "github.com/external-secrets/external-secrets/providers/v1/beyondtrustworkloadcredentials/util"
- )
- const (
- // API documentation URL.
- apiDocsURL = "https://docs.beyondtrust.com/bt-docs/docs/secrets-api"
- // API version header for BeyondTrust Workload Credentials.
- apiVersionHeader = "bt-secrets-api-version"
- apiVersion = "2026-04-28"
- // Default timeout for HTTP requests.
- defaultTimeout = 30 * time.Second
- // Maximum response body size to prevent unbounded memory allocation.
- maxResponseBytes = 10 << 20 // 10 MiB
- )
- // Client represents a client for interacting with BeyondTrust Workload Credentials API.
- type Client struct {
- httpClient *http.Client
- baseURL *url.URL
- token string
- }
- // NewClient creates a new BeyondTrust Workload Credentials HTTP client.
- func NewClient(serverURL, token string) (*Client, error) {
- if err := validateServerURL(serverURL); err != nil {
- return nil, err
- }
- parsedURL, err := url.Parse(strings.TrimSuffix(serverURL, "/"))
- if err != nil {
- return nil, fmt.Errorf("failed to parse server URL %q: %w", serverURL, err)
- }
- return &Client{
- httpClient: &http.Client{
- Timeout: defaultTimeout,
- },
- baseURL: parsedURL,
- token: token,
- }, nil
- }
- // NewClientWithCustomCA creates a client using the provided PEM-encoded CA bundle.
- func NewClientWithCustomCA(serverURL, token string, caBundlePEM []byte) (*Client, error) {
- if err := validateServerURL(serverURL); err != nil {
- return nil, err
- }
- parsedURL, err := url.Parse(strings.TrimSuffix(serverURL, "/"))
- if err != nil {
- return nil, fmt.Errorf("failed to parse server URL %q: %w", serverURL, err)
- }
- httpClient := &http.Client{
- Timeout: defaultTimeout,
- }
- if len(caBundlePEM) > 0 {
- roots := x509.NewCertPool()
- if !roots.AppendCertsFromPEM(caBundlePEM) {
- return nil, fmt.Errorf("failed to parse CA bundle PEM")
- }
- // Clone the default transport to preserve default settings like ProxyFromEnvironment
- transport := http.DefaultTransport.(*http.Transport).Clone()
- transport.TLSClientConfig = &tls.Config{
- RootCAs: roots,
- MinVersion: tls.VersionTLS12,
- }
- httpClient.Transport = transport
- }
- return &Client{
- httpClient: httpClient,
- baseURL: parsedURL,
- token: token,
- }, nil
- }
- // BaseURL returns the base URL of the API.
- func (c *Client) BaseURL() *url.URL {
- if c.baseURL == nil {
- return nil
- }
- u := *c.baseURL
- return &u
- }
- // SetBaseURL sets the base URL for the API.
- func (c *Client) SetBaseURL(urlStr string) error {
- if err := validateServerURL(urlStr); err != nil {
- return err
- }
- baseURL, err := url.Parse(strings.TrimSuffix(urlStr, "/"))
- if err != nil {
- return fmt.Errorf("failed to parse base URL %q: %w", urlStr, err)
- }
- c.baseURL = baseURL
- return nil
- }
- // CheckSession verifies if the current authentication session is valid.
- func (c *Client) CheckSession(ctx context.Context) error {
- endpoint := fmt.Sprintf("%s/session", c.baseURL.String())
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
- if err != nil {
- return fmt.Errorf("failed to create session check request: %w", err)
- }
- c.setHeaders(req)
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return fmt.Errorf("failed to check session: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
- if err != nil {
- return fmt.Errorf("failed to read session check response: %w", err)
- }
- if resp.StatusCode == http.StatusOK {
- // HTTP 200 indicates a valid session
- return nil
- }
- return parseError(body, resp.StatusCode, "/session")
- }
- // GetSecret fetches a single secret by name from the specified folder path.
- func (c *Client) GetSecret(ctx context.Context, name string, folderPath *string) (*btwcutil.KV, error) {
- path := formatPath(folderPath)
- endpoint := fmt.Sprintf("%s/static/%s", c.baseURL.String(), url.PathEscape(name))
- // The single-secret endpoint uses "folder" while the list endpoint uses "path". These are intentionally different parameter names.
- if folderPath != nil && *folderPath != "" {
- endpoint += fmt.Sprintf("?folder=%s", url.QueryEscape(*folderPath))
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
- c.setHeaders(req)
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch secret %q at %q: %w", name, path, err)
- }
- defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
- if resp.StatusCode == http.StatusOK {
- var kv btwcutil.KV
- if err := json.Unmarshal(body, &kv); err != nil {
- return nil, fmt.Errorf("failed to unmarshal secret response: %w", err)
- }
- return &kv, nil
- }
- return nil, parseError(body, resp.StatusCode, fmt.Sprintf("%s/%s", path, name))
- }
- // GetSecrets fetches a list of secrets at the specified folder path.
- func (c *Client) GetSecrets(ctx context.Context, folderPath *string, recursive bool) ([]btwcutil.KVListItem, error) {
- path := formatPath(folderPath)
- endpoint := fmt.Sprintf("%s/static", c.baseURL.String())
- // The list endpoint uses "path" (GET /static?path=...) per the API spec,
- // while the single-secret endpoint uses "folder". These are intentionally different parameter names.
- params := url.Values{}
- if folderPath != nil && *folderPath != "" {
- params.Set("path", *folderPath)
- }
- if recursive {
- params.Set("recursive", "true")
- }
- // Add query string if there are parameters
- if len(params) > 0 {
- endpoint += "?" + params.Encode()
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
- c.setHeaders(req)
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to list secrets: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
- if resp.StatusCode == http.StatusOK {
- var listResp struct {
- Data []btwcutil.KVListItem `json:"data"`
- Error string `json:"error,omitempty"`
- }
- if err := json.Unmarshal(body, &listResp); err != nil {
- return nil, fmt.Errorf("failed to unmarshal list response: %w", err)
- }
- // Check for API error in response body even with 200 status
- if listResp.Error != "" {
- return nil, fmt.Errorf("beyondtrust API error: %s", listResp.Error)
- }
- // Empty folder is valid - return empty list
- if len(listResp.Data) == 0 {
- return []btwcutil.KVListItem{}, nil
- }
- return listResp.Data, nil
- }
- return nil, parseError(body, resp.StatusCode, path)
- }
- // GenerateDynamicSecret calls the dynamic secret generation endpoint.
- func (c *Client) GenerateDynamicSecret(ctx context.Context, secretName string, folderPath *string) (*btwcutil.GeneratedSecret, error) {
- path := formatPath(folderPath)
- endpoint := fmt.Sprintf("%s/dynamic/%s/generate", c.baseURL.String(), url.PathEscape(secretName))
- // Add folder query parameter if specified
- if folderPath != nil && *folderPath != "" {
- endpoint += fmt.Sprintf("?folder=%s", url.QueryEscape(*folderPath))
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, http.NoBody)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
- c.setHeaders(req)
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to generate dynamic secret: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
- if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
- var wrapped struct {
- Secret btwcutil.GeneratedSecret `json:"secret"`
- }
- if err := json.Unmarshal(body, &wrapped); err != nil {
- return nil, fmt.Errorf("failed to unmarshal generated secret response: %w", err)
- }
- return &wrapped.Secret, nil
- }
- return nil, parseError(body, resp.StatusCode, path)
- }
- // setHeaders adds required headers to the HTTP request.
- func (c *Client) setHeaders(req *http.Request) {
- req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
- req.Header.Set(apiVersionHeader, apiVersion)
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- }
- // validateServerURL checks if the provided server URL is valid.
- func validateServerURL(server string) error {
- server = strings.TrimSpace(server)
- if server == "" {
- return fmt.Errorf("server URL is required")
- }
- parsedURL, err := url.ParseRequestURI(server)
- if err != nil {
- return fmt.Errorf("invalid server URL %q: %w", server, err)
- }
- // Validate that the URL has both scheme and host
- if parsedURL.Scheme == "" {
- return fmt.Errorf("server URL %q is missing scheme (e.g., https://)", server)
- }
- if parsedURL.Host == "" {
- return fmt.Errorf("server URL %q is missing host", server)
- }
- return nil
- }
- // formatPath returns the string value of the given path pointer.
- func formatPath(pathPtr *string) string {
- if pathPtr == nil || *pathPtr == "" {
- return "/"
- }
- return *pathPtr
- }
- // parseError attempts to parse an error response from the API.
- func parseError(body []byte, statusCode int, path string) error {
- var errResp errorResponse
- // Try to parse structured error response
- if err := json.Unmarshal(body, &errResp); err == nil {
- var msg strings.Builder
- if errResp.Error != "" {
- msg.WriteString(errResp.Error)
- }
- if errResp.Message != "" {
- if msg.Len() > 0 {
- msg.WriteString(": ")
- }
- msg.WriteString(errResp.Message)
- }
- // Include details if present
- if len(errResp.Details) > 0 {
- detailsJSON, _ := json.Marshal(errResp.Details)
- if msg.Len() > 0 {
- msg.WriteString(" ")
- }
- msg.WriteString(fmt.Sprintf("(details: %s)", string(detailsJSON)))
- }
- if msg.Len() > 0 {
- return &APIError{
- StatusCode: statusCode,
- Message: fmt.Sprintf("API error (HTTP %d): %s at path %q (see %s)", statusCode, msg.String(), path, apiDocsURL),
- Path: path,
- }
- }
- }
- // Fallback error
- return &APIError{
- StatusCode: statusCode,
- Message: fmt.Sprintf("API error (HTTP %d): unexpected response at path %q (see %s)", statusCode, path, apiDocsURL),
- Path: path,
- }
- }
- // Ensure Client implements btwcutil.Client interface.
- var _ btwcutil.Client = (*Client)(nil)
- // NewBeyondtrustWorkloadCredentialsClient is a wrapper for backward compatibility.
- func NewBeyondtrustWorkloadCredentialsClient(server, token string) (btwcutil.Client, error) {
- return NewClient(server, token)
- }
- // NewBeyondtrustWorkloadCredentialsClientWithCustomCA is a wrapper for backward compatibility.
- func NewBeyondtrustWorkloadCredentialsClientWithCustomCA(server, token string, caBundlePEM []byte) (btwcutil.Client, error) {
- return NewClientWithCustomCA(server, token, caBundlePEM)
- }
|