client.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. /*
  2. Copyright © 2025 ESO Maintainer Team
  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 client implements an HTTP client for interacting with the Onboardbase API,
  14. // providing functionality to securely retrieve and manage secrets.
  15. package client
  16. import (
  17. "bytes"
  18. "context"
  19. "crypto/tls"
  20. "encoding/json"
  21. "fmt"
  22. "io"
  23. "net/http"
  24. "net/url"
  25. "strings"
  26. "time"
  27. aesdecrypt "github.com/Onboardbase/go-cryptojs-aes-decrypt/decrypt"
  28. )
  29. const (
  30. // HTTPTimeoutDuration defines the default timeout for HTTP requests.
  31. HTTPTimeoutDuration = 20 * time.Second
  32. // ObbSecretsEndpointPath defines the endpoint path for secrets API.
  33. ObbSecretsEndpointPath = "/secrets"
  34. errUnableToDecrtypt = "unable to decrypt secret payload"
  35. )
  36. // OnboardbaseClient defines the interface for interacting with Onboardbase API.
  37. type OnboardbaseClient struct {
  38. baseURL *url.URL
  39. OnboardbaseAPIKey string
  40. VerifyTLS bool
  41. UserAgent string
  42. OnboardbasePassCode string
  43. httpClient *http.Client
  44. }
  45. type queryParams map[string]string
  46. type headers map[string]string
  47. // DeleteSecretsRequest represents a request to delete secrets from Onboardbase.
  48. type DeleteSecretsRequest struct {
  49. SecretID string `json:"secretId,omitempty"`
  50. }
  51. type httpRequestBody []byte
  52. // Secrets represents a map of secret key-value pairs.
  53. type Secrets map[string]string
  54. // RawSecret represents a raw secret from Onboardbase.
  55. type RawSecret struct {
  56. Key string `json:"key,omitempty"`
  57. Value string `json:"value,omitempty"`
  58. }
  59. // RawSecrets represents a collection of raw secrets.
  60. type RawSecrets []RawSecret
  61. // APIError represents an error response from the Onboardbase API.
  62. type APIError struct {
  63. Err error
  64. Message string
  65. Data string
  66. }
  67. type apiResponse struct {
  68. HTTPResponse *http.Response
  69. Body []byte
  70. }
  71. type apiErrorResponse struct {
  72. Messages []string
  73. Success bool
  74. }
  75. // SecretRequest represents a request for a single secret.
  76. type SecretRequest struct {
  77. Environment string
  78. Project string
  79. Name string
  80. }
  81. // SecretsRequest represents a request for multiple secrets.
  82. type SecretsRequest struct {
  83. Environment string
  84. Project string
  85. }
  86. type secretResponseBodyObject struct {
  87. Title string `json:"title,omitempty"`
  88. ID string `json:"id,omitempty"`
  89. }
  90. type secretResponseSecrets struct {
  91. ID string `json:"id"`
  92. Key string `json:"key"`
  93. Value string `json:"value"`
  94. }
  95. type secretResponseBodyData struct {
  96. Project secretResponseBodyObject `json:"project,omitempty"`
  97. Environment secretResponseBodyObject `json:"environment,omitempty"`
  98. Team secretResponseBodyObject `json:"team,omitempty"`
  99. Secrets []secretResponseSecrets `json:"secrets,omitempty"`
  100. Status string `json:"status"`
  101. Message string `json:"string"`
  102. }
  103. type secretResponseBody struct {
  104. Data secretResponseBodyData `json:"data,omitempty"`
  105. Message string `json:"message,omitempty"`
  106. Status string `json:"status,omitempty"`
  107. }
  108. // SecretResponse represents a single secret response from Onboardbase.
  109. type SecretResponse struct {
  110. Name string
  111. Value string
  112. }
  113. // SecretsResponse represents a collection of secrets from Onboardbase.
  114. type SecretsResponse struct {
  115. Secrets Secrets
  116. Body []byte
  117. }
  118. // NewOnboardbaseClient creates a new client for interacting with Onboardbase API.
  119. // It requires an API key and passcode for authentication.
  120. func NewOnboardbaseClient(onboardbaseAPIKey, onboardbasePasscode string) (*OnboardbaseClient, error) {
  121. tlsConfig := &tls.Config{
  122. MinVersion: tls.VersionTLS12,
  123. }
  124. httpTransport := &http.Transport{
  125. DisableKeepAlives: true,
  126. TLSClientConfig: tlsConfig,
  127. }
  128. client := &OnboardbaseClient{
  129. OnboardbaseAPIKey: onboardbaseAPIKey,
  130. OnboardbasePassCode: onboardbasePasscode,
  131. VerifyTLS: true,
  132. UserAgent: "onboardbase-external-secrets",
  133. httpClient: &http.Client{
  134. Timeout: HTTPTimeoutDuration,
  135. Transport: httpTransport,
  136. },
  137. }
  138. if err := client.SetBaseURL("https://public.onboardbase.com/api/v1/"); err != nil {
  139. return nil, &APIError{Err: err, Message: "setting base URL failed"}
  140. }
  141. return client, nil
  142. }
  143. // BaseURL returns the base URL of the Onboardbase API.
  144. func (c *OnboardbaseClient) BaseURL() *url.URL {
  145. u := *c.baseURL
  146. return &u
  147. }
  148. // SetBaseURL updates the base URL for the Onboardbase API client.
  149. func (c *OnboardbaseClient) SetBaseURL(urlStr string) error {
  150. baseURL, err := url.Parse(strings.TrimSuffix(urlStr, "/"))
  151. if err != nil {
  152. return err
  153. }
  154. c.baseURL = baseURL
  155. return nil
  156. }
  157. // Authenticate verifies the API credentials with Onboardbase.
  158. func (c *OnboardbaseClient) Authenticate() error {
  159. _, err := c.performRequest(
  160. &performRequestConfig{
  161. path: "/team/members",
  162. method: "GET",
  163. headers: headers{},
  164. params: queryParams{},
  165. body: httpRequestBody{},
  166. })
  167. if err != nil {
  168. return err
  169. }
  170. return nil
  171. }
  172. func (c *OnboardbaseClient) getSecretsFromPayload(data secretResponseBodyData) (map[string]string, error) {
  173. kv := make(map[string]string)
  174. for _, secret := range data.Secrets {
  175. passphrase := c.OnboardbasePassCode
  176. key, err := aesdecrypt.Run(secret.Key, passphrase)
  177. if err != nil {
  178. return nil, &APIError{Err: err, Message: errUnableToDecrtypt, Data: secret.Key}
  179. }
  180. value, err := aesdecrypt.Run(secret.Value, passphrase)
  181. if err != nil {
  182. return nil, &APIError{Err: err, Message: errUnableToDecrtypt, Data: secret.Value}
  183. }
  184. kv[key] = value
  185. }
  186. return kv, nil
  187. }
  188. func (c *OnboardbaseClient) mapSecretsByPlainKey(data secretResponseBodyData) (map[string]secretResponseSecrets, error) {
  189. kv := make(map[string]secretResponseSecrets)
  190. for _, secret := range data.Secrets {
  191. passphrase := c.OnboardbasePassCode
  192. key, err := aesdecrypt.Run(secret.Key, passphrase)
  193. if err != nil {
  194. return nil, &APIError{Err: err, Message: errUnableToDecrtypt, Data: secret.Key}
  195. }
  196. kv[key] = secret
  197. }
  198. return kv, nil
  199. }
  200. // GetSecret retrieves a specific secret from Onboardbase.
  201. func (c *OnboardbaseClient) GetSecret(request SecretRequest) (*SecretResponse, error) {
  202. response, err := c.performRequest(
  203. &performRequestConfig{
  204. path: ObbSecretsEndpointPath,
  205. method: "GET",
  206. headers: headers{},
  207. params: request.buildQueryParams(),
  208. body: httpRequestBody{},
  209. })
  210. if err != nil {
  211. return nil, err
  212. }
  213. var data secretResponseBody
  214. if err := json.Unmarshal(response.Body, &data); err != nil {
  215. return nil, &APIError{Err: err, Message: "unable to unmarshal secret payload", Data: string(response.Body)}
  216. }
  217. secrets, _ := c.getSecretsFromPayload(data.Data)
  218. secret := secrets[request.Name]
  219. if secret == "" {
  220. return nil, &APIError{Message: fmt.Sprintf("secret %s for project '%s' and environment '%s' not found", request.Name, request.Project, request.Environment)}
  221. }
  222. return &SecretResponse{Name: request.Name, Value: secrets[request.Name]}, nil
  223. }
  224. // DeleteSecret removes a secret from Onboardbase.
  225. func (c *OnboardbaseClient) DeleteSecret(request SecretRequest) error {
  226. secretsrequest := SecretsRequest{
  227. Project: request.Project,
  228. Environment: request.Environment,
  229. }
  230. secretsData, _, err := c.makeGetSecretsRequest(secretsrequest)
  231. if err != nil {
  232. return err
  233. }
  234. secrets, err := c.mapSecretsByPlainKey(secretsData.Data)
  235. if err != nil {
  236. return err
  237. }
  238. secret, ok := secrets[request.Name]
  239. if !ok || secret.ID == "" {
  240. return nil
  241. }
  242. params := request.buildQueryParams()
  243. deleteSecretDto := &DeleteSecretsRequest{
  244. SecretID: secret.ID,
  245. }
  246. body, jsonErr := json.Marshal(deleteSecretDto)
  247. if jsonErr != nil {
  248. return &APIError{Err: jsonErr, Message: "unable to unmarshal delete secrets payload"}
  249. }
  250. _, err = c.performRequest(&performRequestConfig{
  251. path: ObbSecretsEndpointPath,
  252. method: "DELETE",
  253. headers: headers{},
  254. params: params,
  255. body: body,
  256. })
  257. if err != nil {
  258. return err
  259. }
  260. return nil
  261. }
  262. func (c *OnboardbaseClient) makeGetSecretsRequest(request SecretsRequest) (*secretResponseBody, *apiResponse, error) {
  263. response, apiErr := c.performRequest(&performRequestConfig{
  264. path: ObbSecretsEndpointPath,
  265. method: "GET",
  266. headers: headers{},
  267. params: request.buildQueryParams(),
  268. body: httpRequestBody{},
  269. })
  270. if apiErr != nil {
  271. return nil, nil, apiErr
  272. }
  273. var data *secretResponseBody
  274. if err := json.Unmarshal(response.Body, &data); err != nil {
  275. return nil, nil, &APIError{Err: err, Message: "unable to unmarshal secret payload", Data: string(response.Body)}
  276. }
  277. return data, response, nil
  278. }
  279. // GetSecrets retrieves multiple secrets from Onboardbase.
  280. func (c *OnboardbaseClient) GetSecrets(request SecretsRequest) (*SecretsResponse, error) {
  281. data, response, err := c.makeGetSecretsRequest(request)
  282. if err != nil {
  283. return nil, err
  284. }
  285. secrets, _ := c.getSecretsFromPayload(data.Data)
  286. return &SecretsResponse{Secrets: secrets, Body: response.Body}, nil
  287. }
  288. func (r *SecretsRequest) buildQueryParams() queryParams {
  289. params := queryParams{}
  290. if r.Project != "" {
  291. params["project"] = r.Project
  292. }
  293. if r.Environment != "" {
  294. params["environment"] = r.Environment
  295. }
  296. return params
  297. }
  298. func (r *SecretRequest) buildQueryParams() queryParams {
  299. params := queryParams{}
  300. if r.Project != "" {
  301. params["project"] = r.Project
  302. }
  303. if r.Environment != "" {
  304. params["environment"] = r.Environment
  305. }
  306. return params
  307. }
  308. type performRequestConfig struct {
  309. path string
  310. method string
  311. headers headers
  312. params queryParams
  313. body httpRequestBody
  314. }
  315. func (c *OnboardbaseClient) performRequest(config *performRequestConfig) (*apiResponse, error) {
  316. urlStr := c.BaseURL().String() + config.path
  317. reqURL, err := url.Parse(urlStr)
  318. if err != nil {
  319. return nil, &APIError{Err: err, Message: fmt.Sprintf("invalid API URL: %s", urlStr)}
  320. }
  321. var bodyReader io.Reader
  322. if config.body != nil {
  323. bodyReader = bytes.NewReader(config.body)
  324. } else {
  325. bodyReader = http.NoBody
  326. }
  327. // timeout this request after 20 seconds
  328. ctx, cancel := context.WithTimeout(context.Background(), HTTPTimeoutDuration)
  329. defer cancel()
  330. req, err := http.NewRequestWithContext(ctx, config.method, reqURL.String(), bodyReader)
  331. if err != nil {
  332. return nil, &APIError{Err: err, Message: "unable to form HTTP request"}
  333. }
  334. req.Header.Set("content-type", "application/json")
  335. req.Header.Set("user-agent", c.UserAgent)
  336. req.Header.Set("api_key", c.OnboardbaseAPIKey)
  337. for key, value := range config.headers {
  338. req.Header.Set(key, value)
  339. }
  340. query := req.URL.Query()
  341. for key, value := range config.params {
  342. query.Add(key, value)
  343. }
  344. req.URL.RawQuery = query.Encode()
  345. r, err := c.httpClient.Do(req)
  346. if err != nil {
  347. return nil, &APIError{Err: err, Message: "unable to load response"}
  348. }
  349. defer func() {
  350. _ = r.Body.Close()
  351. }()
  352. bodyResponse, err := io.ReadAll(r.Body)
  353. if err != nil {
  354. return nil, &APIError{Err: err, Message: "unable to read entire response body"}
  355. }
  356. response := &apiResponse{HTTPResponse: r, Body: bodyResponse}
  357. success := isSuccess(r.StatusCode)
  358. if !success {
  359. return handlePerformRequestFailure(response)
  360. }
  361. if success && err != nil {
  362. return nil, &APIError{Err: err, Message: "unable to load data from successful response"}
  363. }
  364. return response, nil
  365. }
  366. func handlePerformRequestFailure(response *apiResponse) (*apiResponse, *APIError) {
  367. if contentType := response.HTTPResponse.Header.Get("content-type"); strings.HasPrefix(contentType, "application/json") {
  368. var errResponse apiErrorResponse
  369. err := json.Unmarshal(response.Body, &errResponse)
  370. if err != nil {
  371. return response, &APIError{Err: err, Message: "unable to unmarshal error JSON payload"}
  372. }
  373. return response, &APIError{Err: nil, Message: strings.Join(errResponse.Messages, "\n")}
  374. }
  375. return nil, &APIError{Err: fmt.Errorf("%d status code; %d bytes", response.HTTPResponse.StatusCode, len(response.Body)), Message: "unable to load response"}
  376. }
  377. func isSuccess(statusCode int) bool {
  378. return (statusCode >= 200 && statusCode <= 299) || (statusCode >= 300 && statusCode <= 399)
  379. }
  380. func (e *APIError) Error() string {
  381. message := fmt.Sprintf("Onboardbase API Client Error: %s", e.Message)
  382. if underlyingError := e.Err; underlyingError != nil {
  383. message = fmt.Sprintf("%s\n%s", message, underlyingError.Error())
  384. }
  385. if e.Data != "" {
  386. message = fmt.Sprintf("%s\nData: %s", message, e.Data)
  387. }
  388. return message
  389. }