client.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. /*
  2. Copyright © The ESO Authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. https://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. // Package httpclient provides an HTTP client for interacting with BeyondTrust Workload Credentials API.
  14. // API Documentation: https://docs.beyondtrust.com/bt-docs/docs/secrets-api
  15. package httpclient
  16. import (
  17. "context"
  18. "crypto/tls"
  19. "crypto/x509"
  20. "encoding/json"
  21. "fmt"
  22. "io"
  23. "net/http"
  24. "net/url"
  25. "strings"
  26. "time"
  27. btwcutil "github.com/external-secrets/external-secrets/providers/v1/beyondtrustworkloadcredentials/util"
  28. )
  29. const (
  30. // API documentation URL.
  31. apiDocsURL = "https://docs.beyondtrust.com/bt-docs/docs/secrets-api"
  32. // API version header for BeyondTrust Workload Credentials.
  33. apiVersionHeader = "bt-secrets-api-version"
  34. apiVersion = "2026-04-28"
  35. // Default timeout for HTTP requests.
  36. defaultTimeout = 30 * time.Second
  37. // Maximum response body size to prevent unbounded memory allocation.
  38. maxResponseBytes = 10 << 20 // 10 MiB
  39. )
  40. // Client represents a client for interacting with BeyondTrust Workload Credentials API.
  41. type Client struct {
  42. httpClient *http.Client
  43. baseURL *url.URL
  44. token string
  45. }
  46. // NewClient creates a new BeyondTrust Workload Credentials HTTP client.
  47. func NewClient(serverURL, token string) (*Client, error) {
  48. if err := validateServerURL(serverURL); err != nil {
  49. return nil, err
  50. }
  51. parsedURL, err := url.Parse(strings.TrimSuffix(serverURL, "/"))
  52. if err != nil {
  53. return nil, fmt.Errorf("failed to parse server URL %q: %w", serverURL, err)
  54. }
  55. return &Client{
  56. httpClient: &http.Client{
  57. Timeout: defaultTimeout,
  58. },
  59. baseURL: parsedURL,
  60. token: token,
  61. }, nil
  62. }
  63. // NewClientWithCustomCA creates a client using the provided PEM-encoded CA bundle.
  64. func NewClientWithCustomCA(serverURL, token string, caBundlePEM []byte) (*Client, error) {
  65. if err := validateServerURL(serverURL); err != nil {
  66. return nil, err
  67. }
  68. parsedURL, err := url.Parse(strings.TrimSuffix(serverURL, "/"))
  69. if err != nil {
  70. return nil, fmt.Errorf("failed to parse server URL %q: %w", serverURL, err)
  71. }
  72. httpClient := &http.Client{
  73. Timeout: defaultTimeout,
  74. }
  75. if len(caBundlePEM) > 0 {
  76. roots := x509.NewCertPool()
  77. if !roots.AppendCertsFromPEM(caBundlePEM) {
  78. return nil, fmt.Errorf("failed to parse CA bundle PEM")
  79. }
  80. // Clone the default transport to preserve default settings like ProxyFromEnvironment
  81. transport := http.DefaultTransport.(*http.Transport).Clone()
  82. transport.TLSClientConfig = &tls.Config{
  83. RootCAs: roots,
  84. MinVersion: tls.VersionTLS12,
  85. }
  86. httpClient.Transport = transport
  87. }
  88. return &Client{
  89. httpClient: httpClient,
  90. baseURL: parsedURL,
  91. token: token,
  92. }, nil
  93. }
  94. // BaseURL returns the base URL of the API.
  95. func (c *Client) BaseURL() *url.URL {
  96. if c.baseURL == nil {
  97. return nil
  98. }
  99. u := *c.baseURL
  100. return &u
  101. }
  102. // SetBaseURL sets the base URL for the API.
  103. func (c *Client) SetBaseURL(urlStr string) error {
  104. if err := validateServerURL(urlStr); err != nil {
  105. return err
  106. }
  107. baseURL, err := url.Parse(strings.TrimSuffix(urlStr, "/"))
  108. if err != nil {
  109. return fmt.Errorf("failed to parse base URL %q: %w", urlStr, err)
  110. }
  111. c.baseURL = baseURL
  112. return nil
  113. }
  114. // CheckSession verifies if the current authentication session is valid.
  115. func (c *Client) CheckSession(ctx context.Context) error {
  116. endpoint := fmt.Sprintf("%s/session", c.baseURL.String())
  117. req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
  118. if err != nil {
  119. return fmt.Errorf("failed to create session check request: %w", err)
  120. }
  121. c.setHeaders(req)
  122. resp, err := c.httpClient.Do(req)
  123. if err != nil {
  124. return fmt.Errorf("failed to check session: %w", err)
  125. }
  126. defer func() { _ = resp.Body.Close() }()
  127. body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
  128. if err != nil {
  129. return fmt.Errorf("failed to read session check response: %w", err)
  130. }
  131. if resp.StatusCode == http.StatusOK {
  132. // HTTP 200 indicates a valid session
  133. return nil
  134. }
  135. return parseError(body, resp.StatusCode, "/session")
  136. }
  137. // GetSecret fetches a single secret by name from the specified folder path.
  138. func (c *Client) GetSecret(ctx context.Context, name string, folderPath *string) (*btwcutil.KV, error) {
  139. path := formatPath(folderPath)
  140. endpoint := fmt.Sprintf("%s/static/%s", c.baseURL.String(), url.PathEscape(name))
  141. // The single-secret endpoint uses "folder" while the list endpoint uses "path". These are intentionally different parameter names.
  142. if folderPath != nil && *folderPath != "" {
  143. endpoint += fmt.Sprintf("?folder=%s", url.QueryEscape(*folderPath))
  144. }
  145. req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
  146. if err != nil {
  147. return nil, fmt.Errorf("failed to create request: %w", err)
  148. }
  149. c.setHeaders(req)
  150. resp, err := c.httpClient.Do(req)
  151. if err != nil {
  152. return nil, fmt.Errorf("failed to fetch secret %q at %q: %w", name, path, err)
  153. }
  154. defer func() { _ = resp.Body.Close() }()
  155. body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
  156. if err != nil {
  157. return nil, fmt.Errorf("failed to read response body: %w", err)
  158. }
  159. if resp.StatusCode == http.StatusOK {
  160. var kv btwcutil.KV
  161. if err := json.Unmarshal(body, &kv); err != nil {
  162. return nil, fmt.Errorf("failed to unmarshal secret response: %w", err)
  163. }
  164. return &kv, nil
  165. }
  166. return nil, parseError(body, resp.StatusCode, fmt.Sprintf("%s/%s", path, name))
  167. }
  168. // GetSecrets fetches a list of secrets at the specified folder path.
  169. func (c *Client) GetSecrets(ctx context.Context, folderPath *string, recursive bool) ([]btwcutil.KVListItem, error) {
  170. path := formatPath(folderPath)
  171. endpoint := fmt.Sprintf("%s/static", c.baseURL.String())
  172. // The list endpoint uses "path" (GET /static?path=...) per the API spec,
  173. // while the single-secret endpoint uses "folder". These are intentionally different parameter names.
  174. params := url.Values{}
  175. if folderPath != nil && *folderPath != "" {
  176. params.Set("path", *folderPath)
  177. }
  178. if recursive {
  179. params.Set("recursive", "true")
  180. }
  181. // Add query string if there are parameters
  182. if len(params) > 0 {
  183. endpoint += "?" + params.Encode()
  184. }
  185. req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
  186. if err != nil {
  187. return nil, fmt.Errorf("failed to create request: %w", err)
  188. }
  189. c.setHeaders(req)
  190. resp, err := c.httpClient.Do(req)
  191. if err != nil {
  192. return nil, fmt.Errorf("failed to list secrets: %w", err)
  193. }
  194. defer func() { _ = resp.Body.Close() }()
  195. body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
  196. if err != nil {
  197. return nil, fmt.Errorf("failed to read response body: %w", err)
  198. }
  199. if resp.StatusCode == http.StatusOK {
  200. var listResp struct {
  201. Data []btwcutil.KVListItem `json:"data"`
  202. Error string `json:"error,omitempty"`
  203. }
  204. if err := json.Unmarshal(body, &listResp); err != nil {
  205. return nil, fmt.Errorf("failed to unmarshal list response: %w", err)
  206. }
  207. // Check for API error in response body even with 200 status
  208. if listResp.Error != "" {
  209. return nil, fmt.Errorf("beyondtrust API error: %s", listResp.Error)
  210. }
  211. // Empty folder is valid - return empty list
  212. if len(listResp.Data) == 0 {
  213. return []btwcutil.KVListItem{}, nil
  214. }
  215. return listResp.Data, nil
  216. }
  217. return nil, parseError(body, resp.StatusCode, path)
  218. }
  219. // GenerateDynamicSecret calls the dynamic secret generation endpoint.
  220. func (c *Client) GenerateDynamicSecret(ctx context.Context, secretName string, folderPath *string) (*btwcutil.GeneratedSecret, error) {
  221. path := formatPath(folderPath)
  222. endpoint := fmt.Sprintf("%s/dynamic/%s/generate", c.baseURL.String(), url.PathEscape(secretName))
  223. // Add folder query parameter if specified
  224. if folderPath != nil && *folderPath != "" {
  225. endpoint += fmt.Sprintf("?folder=%s", url.QueryEscape(*folderPath))
  226. }
  227. req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, http.NoBody)
  228. if err != nil {
  229. return nil, fmt.Errorf("failed to create request: %w", err)
  230. }
  231. c.setHeaders(req)
  232. resp, err := c.httpClient.Do(req)
  233. if err != nil {
  234. return nil, fmt.Errorf("failed to generate dynamic secret: %w", err)
  235. }
  236. defer func() { _ = resp.Body.Close() }()
  237. body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
  238. if err != nil {
  239. return nil, fmt.Errorf("failed to read response body: %w", err)
  240. }
  241. if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
  242. var wrapped struct {
  243. Secret btwcutil.GeneratedSecret `json:"secret"`
  244. }
  245. if err := json.Unmarshal(body, &wrapped); err != nil {
  246. return nil, fmt.Errorf("failed to unmarshal generated secret response: %w", err)
  247. }
  248. return &wrapped.Secret, nil
  249. }
  250. return nil, parseError(body, resp.StatusCode, path)
  251. }
  252. // setHeaders adds required headers to the HTTP request.
  253. func (c *Client) setHeaders(req *http.Request) {
  254. req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
  255. req.Header.Set(apiVersionHeader, apiVersion)
  256. req.Header.Set("Content-Type", "application/json")
  257. req.Header.Set("Accept", "application/json")
  258. }
  259. // validateServerURL checks if the provided server URL is valid.
  260. func validateServerURL(server string) error {
  261. server = strings.TrimSpace(server)
  262. if server == "" {
  263. return fmt.Errorf("server URL is required")
  264. }
  265. parsedURL, err := url.ParseRequestURI(server)
  266. if err != nil {
  267. return fmt.Errorf("invalid server URL %q: %w", server, err)
  268. }
  269. // Validate that the URL has both scheme and host
  270. if parsedURL.Scheme == "" {
  271. return fmt.Errorf("server URL %q is missing scheme (e.g., https://)", server)
  272. }
  273. if parsedURL.Host == "" {
  274. return fmt.Errorf("server URL %q is missing host", server)
  275. }
  276. return nil
  277. }
  278. // formatPath returns the string value of the given path pointer.
  279. func formatPath(pathPtr *string) string {
  280. if pathPtr == nil || *pathPtr == "" {
  281. return "/"
  282. }
  283. return *pathPtr
  284. }
  285. // parseError attempts to parse an error response from the API.
  286. func parseError(body []byte, statusCode int, path string) error {
  287. var errResp errorResponse
  288. // Try to parse structured error response
  289. if err := json.Unmarshal(body, &errResp); err == nil {
  290. var msg strings.Builder
  291. if errResp.Error != "" {
  292. msg.WriteString(errResp.Error)
  293. }
  294. if errResp.Message != "" {
  295. if msg.Len() > 0 {
  296. msg.WriteString(": ")
  297. }
  298. msg.WriteString(errResp.Message)
  299. }
  300. // Include details if present
  301. if len(errResp.Details) > 0 {
  302. detailsJSON, _ := json.Marshal(errResp.Details)
  303. if msg.Len() > 0 {
  304. msg.WriteString(" ")
  305. }
  306. msg.WriteString(fmt.Sprintf("(details: %s)", string(detailsJSON)))
  307. }
  308. if msg.Len() > 0 {
  309. return &APIError{
  310. StatusCode: statusCode,
  311. Message: fmt.Sprintf("API error (HTTP %d): %s at path %q (see %s)", statusCode, msg.String(), path, apiDocsURL),
  312. Path: path,
  313. }
  314. }
  315. }
  316. // Fallback error
  317. return &APIError{
  318. StatusCode: statusCode,
  319. Message: fmt.Sprintf("API error (HTTP %d): unexpected response at path %q (see %s)", statusCode, path, apiDocsURL),
  320. Path: path,
  321. }
  322. }
  323. // Ensure Client implements btwcutil.Client interface.
  324. var _ btwcutil.Client = (*Client)(nil)
  325. // NewBeyondtrustWorkloadCredentialsClient is a wrapper for backward compatibility.
  326. func NewBeyondtrustWorkloadCredentialsClient(server, token string) (btwcutil.Client, error) {
  327. return NewClient(server, token)
  328. }
  329. // NewBeyondtrustWorkloadCredentialsClientWithCustomCA is a wrapper for backward compatibility.
  330. func NewBeyondtrustWorkloadCredentialsClientWithCustomCA(server, token string, caBundlePEM []byte) (btwcutil.Client, error) {
  331. return NewClientWithCustomCA(server, token, caBundlePEM)
  332. }