client.go 11 KB

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