|
@@ -0,0 +1,436 @@
|
|
|
|
|
+/*
|
|
|
|
|
+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
|
|
|
|
|
+
|
|
|
|
|
+ http://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 client
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "bytes"
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "crypto/tls"
|
|
|
|
|
+ "encoding/json"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "io"
|
|
|
|
|
+ "net/http"
|
|
|
|
|
+ "net/url"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "time"
|
|
|
|
|
+
|
|
|
|
|
+ aesdecrypt "github.com/Onboardbase/go-cryptojs-aes-decrypt/decrypt"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+const HTTPTimeoutDuration = 20 * time.Second
|
|
|
|
|
+const ObbSecretsEndpointPath = "/secrets"
|
|
|
|
|
+
|
|
|
|
|
+const errUnableToDecrtypt = "unable to decrypt secret payload"
|
|
|
|
|
+
|
|
|
|
|
+type OnboardbaseClient struct {
|
|
|
|
|
+ baseURL *url.URL
|
|
|
|
|
+ OnboardbaseAPIKey string
|
|
|
|
|
+ VerifyTLS bool
|
|
|
|
|
+ UserAgent string
|
|
|
|
|
+ OnboardbasePassCode string
|
|
|
|
|
+ httpClient *http.Client
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type queryParams map[string]string
|
|
|
|
|
+
|
|
|
|
|
+type headers map[string]string
|
|
|
|
|
+type DeleteSecretsRequest struct {
|
|
|
|
|
+ SecretID string `json:"secretId,omitempty"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type httpRequestBody []byte
|
|
|
|
|
+
|
|
|
|
|
+type Secrets map[string]string
|
|
|
|
|
+
|
|
|
|
|
+type RawSecret struct {
|
|
|
|
|
+ Key string `json:"key,omitempty"`
|
|
|
|
|
+ Value string `json:"value,omitempty"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type RawSecrets []RawSecret
|
|
|
|
|
+
|
|
|
|
|
+type APIError struct {
|
|
|
|
|
+ Err error
|
|
|
|
|
+ Message string
|
|
|
|
|
+ Data string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type apiResponse struct {
|
|
|
|
|
+ HTTPResponse *http.Response
|
|
|
|
|
+ Body []byte
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type apiErrorResponse struct {
|
|
|
|
|
+ Messages []string
|
|
|
|
|
+ Success bool
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type SecretRequest struct {
|
|
|
|
|
+ Environment string
|
|
|
|
|
+ Project string
|
|
|
|
|
+ Name string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type SecretsRequest struct {
|
|
|
|
|
+ Environment string
|
|
|
|
|
+ Project string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type secretResponseBodyObject struct {
|
|
|
|
|
+ Title string `json:"title,omitempty"`
|
|
|
|
|
+ ID string `json:"id,omitempty"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type secretResponseSecrets struct {
|
|
|
|
|
+ ID string `json:"id"`
|
|
|
|
|
+ Key string `json:"key"`
|
|
|
|
|
+ Value string `json:"value"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type secretResponseBodyData struct {
|
|
|
|
|
+ Project secretResponseBodyObject `json:"project,omitempty"`
|
|
|
|
|
+ Environment secretResponseBodyObject `json:"environment,omitempty"`
|
|
|
|
|
+ Team secretResponseBodyObject `json:"team,omitempty"`
|
|
|
|
|
+ Secrets []secretResponseSecrets `json:"secrets,omitempty"`
|
|
|
|
|
+ Status string `json:"status"`
|
|
|
|
|
+ Message string `json:"string"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type secretResponseBody struct {
|
|
|
|
|
+ Data secretResponseBodyData `json:"data,omitempty"`
|
|
|
|
|
+ Message string `json:"message,omitempty"`
|
|
|
|
|
+ Status string `json:"status,omitempty"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type SecretResponse struct {
|
|
|
|
|
+ Name string
|
|
|
|
|
+ Value string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type SecretsResponse struct {
|
|
|
|
|
+ Secrets Secrets
|
|
|
|
|
+ Body []byte
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func NewOnboardbaseClient(onboardbaseAPIKey, onboardbasePasscode string) (*OnboardbaseClient, error) {
|
|
|
|
|
+ tlsConfig := &tls.Config{
|
|
|
|
|
+ MinVersion: tls.VersionTLS12,
|
|
|
|
|
+ }
|
|
|
|
|
+ httpTransport := &http.Transport{
|
|
|
|
|
+ DisableKeepAlives: true,
|
|
|
|
|
+ TLSClientConfig: tlsConfig,
|
|
|
|
|
+ }
|
|
|
|
|
+ client := &OnboardbaseClient{
|
|
|
|
|
+ OnboardbaseAPIKey: onboardbaseAPIKey,
|
|
|
|
|
+ OnboardbasePassCode: onboardbasePasscode,
|
|
|
|
|
+ VerifyTLS: true,
|
|
|
|
|
+ UserAgent: "onboardbase-external-secrets",
|
|
|
|
|
+ httpClient: &http.Client{
|
|
|
|
|
+ Timeout: HTTPTimeoutDuration,
|
|
|
|
|
+ Transport: httpTransport,
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if err := client.SetBaseURL("https://public.onboardbase.com/api/v1/"); err != nil {
|
|
|
|
|
+ return nil, &APIError{Err: err, Message: "setting base URL failed"}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return client, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (c *OnboardbaseClient) BaseURL() *url.URL {
|
|
|
|
|
+ u := *c.baseURL
|
|
|
|
|
+ return &u
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (c *OnboardbaseClient) SetBaseURL(urlStr string) error {
|
|
|
|
|
+ baseURL, err := url.Parse(strings.TrimSuffix(urlStr, "/"))
|
|
|
|
|
+
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ c.baseURL = baseURL
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (c *OnboardbaseClient) Authenticate() error {
|
|
|
|
|
+ _, err := c.performRequest(
|
|
|
|
|
+ &performRequestConfig{
|
|
|
|
|
+ path: "/team/members",
|
|
|
|
|
+ method: "GET",
|
|
|
|
|
+ headers: headers{},
|
|
|
|
|
+ params: queryParams{},
|
|
|
|
|
+ body: httpRequestBody{},
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (c *OnboardbaseClient) getSecretsFromPayload(data secretResponseBodyData) (map[string]string, error) {
|
|
|
|
|
+ kv := make(map[string]string)
|
|
|
|
|
+ for _, secret := range data.Secrets {
|
|
|
|
|
+ passphrase := c.OnboardbasePassCode
|
|
|
|
|
+ key, err := aesdecrypt.Run(secret.Key, passphrase)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, &APIError{Err: err, Message: errUnableToDecrtypt, Data: secret.Key}
|
|
|
|
|
+ }
|
|
|
|
|
+ value, err := aesdecrypt.Run(secret.Value, passphrase)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, &APIError{Err: err, Message: errUnableToDecrtypt, Data: secret.Value}
|
|
|
|
|
+ }
|
|
|
|
|
+ kv[key] = value
|
|
|
|
|
+ }
|
|
|
|
|
+ return kv, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (c *OnboardbaseClient) mapSecretsByPlainKey(data secretResponseBodyData) (map[string]secretResponseSecrets, error) {
|
|
|
|
|
+ kv := make(map[string]secretResponseSecrets)
|
|
|
|
|
+ for _, secret := range data.Secrets {
|
|
|
|
|
+ passphrase := c.OnboardbasePassCode
|
|
|
|
|
+ key, err := aesdecrypt.Run(secret.Key, passphrase)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, &APIError{Err: err, Message: errUnableToDecrtypt, Data: secret.Key}
|
|
|
|
|
+ }
|
|
|
|
|
+ kv[key] = secret
|
|
|
|
|
+ }
|
|
|
|
|
+ return kv, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (c *OnboardbaseClient) GetSecret(request SecretRequest) (*SecretResponse, error) {
|
|
|
|
|
+ response, err := c.performRequest(
|
|
|
|
|
+ &performRequestConfig{
|
|
|
|
|
+ path: ObbSecretsEndpointPath,
|
|
|
|
|
+ method: "GET",
|
|
|
|
|
+ headers: headers{},
|
|
|
|
|
+ params: request.buildQueryParams(),
|
|
|
|
|
+ body: httpRequestBody{},
|
|
|
|
|
+ })
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var data secretResponseBody
|
|
|
|
|
+ if err := json.Unmarshal(response.Body, &data); err != nil {
|
|
|
|
|
+ return nil, &APIError{Err: err, Message: "unable to unmarshal secret payload", Data: string(response.Body)}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ secrets, _ := c.getSecretsFromPayload(data.Data)
|
|
|
|
|
+ secret := secrets[request.Name]
|
|
|
|
|
+
|
|
|
|
|
+ if secret == "" {
|
|
|
|
|
+ return nil, &APIError{Message: fmt.Sprintf("secret %s for project '%s' and environment '%s' not found", request.Name, request.Project, request.Environment)}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return &SecretResponse{Name: request.Name, Value: secrets[request.Name]}, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (c *OnboardbaseClient) DeleteSecret(request SecretRequest) error {
|
|
|
|
|
+ secretsrequest := SecretsRequest{
|
|
|
|
|
+ Project: request.Project,
|
|
|
|
|
+ Environment: request.Environment,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ secretsData, _, err := c.makeGetSecretsRequest(secretsrequest)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ secrets, err := c.mapSecretsByPlainKey(secretsData.Data)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ secret, ok := secrets[request.Name]
|
|
|
|
|
+ if !ok || secret.ID == "" {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ params := request.buildQueryParams()
|
|
|
|
|
+ deleteSecretDto := &DeleteSecretsRequest{
|
|
|
|
|
+ SecretID: secret.ID,
|
|
|
|
|
+ }
|
|
|
|
|
+ body, jsonErr := json.Marshal(deleteSecretDto)
|
|
|
|
|
+ if jsonErr != nil {
|
|
|
|
|
+ return &APIError{Err: jsonErr, Message: "unable to unmarshal delete secrets payload"}
|
|
|
|
|
+ }
|
|
|
|
|
+ _, err = c.performRequest(&performRequestConfig{
|
|
|
|
|
+ path: ObbSecretsEndpointPath,
|
|
|
|
|
+ method: "DELETE",
|
|
|
|
|
+ headers: headers{},
|
|
|
|
|
+ params: params,
|
|
|
|
|
+ body: body,
|
|
|
|
|
+ })
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (c *OnboardbaseClient) makeGetSecretsRequest(request SecretsRequest) (*secretResponseBody, *apiResponse, error) {
|
|
|
|
|
+ response, apiErr := c.performRequest(&performRequestConfig{
|
|
|
|
|
+ path: ObbSecretsEndpointPath,
|
|
|
|
|
+ method: "GET",
|
|
|
|
|
+ headers: headers{},
|
|
|
|
|
+ params: request.buildQueryParams(),
|
|
|
|
|
+ body: httpRequestBody{},
|
|
|
|
|
+ })
|
|
|
|
|
+ if apiErr != nil {
|
|
|
|
|
+ return nil, nil, apiErr
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var data *secretResponseBody
|
|
|
|
|
+ if err := json.Unmarshal(response.Body, &data); err != nil {
|
|
|
|
|
+ return nil, nil, &APIError{Err: err, Message: "unable to unmarshal secret payload", Data: string(response.Body)}
|
|
|
|
|
+ }
|
|
|
|
|
+ return data, response, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (c *OnboardbaseClient) GetSecrets(request SecretsRequest) (*SecretsResponse, error) {
|
|
|
|
|
+ data, response, err := c.makeGetSecretsRequest(request)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ secrets, _ := c.getSecretsFromPayload(data.Data)
|
|
|
|
|
+ return &SecretsResponse{Secrets: secrets, Body: response.Body}, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (r *SecretsRequest) buildQueryParams() queryParams {
|
|
|
|
|
+ params := queryParams{}
|
|
|
|
|
+
|
|
|
|
|
+ if r.Project != "" {
|
|
|
|
|
+ params["project"] = r.Project
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if r.Environment != "" {
|
|
|
|
|
+ params["environment"] = r.Environment
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return params
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (r *SecretRequest) buildQueryParams() queryParams {
|
|
|
|
|
+ params := queryParams{}
|
|
|
|
|
+
|
|
|
|
|
+ if r.Project != "" {
|
|
|
|
|
+ params["project"] = r.Project
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if r.Environment != "" {
|
|
|
|
|
+ params["environment"] = r.Environment
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return params
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type performRequestConfig struct {
|
|
|
|
|
+ path string
|
|
|
|
|
+ method string
|
|
|
|
|
+ headers headers
|
|
|
|
|
+ params queryParams
|
|
|
|
|
+ body httpRequestBody
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (c *OnboardbaseClient) performRequest(config *performRequestConfig) (*apiResponse, error) {
|
|
|
|
|
+ urlStr := c.BaseURL().String() + config.path
|
|
|
|
|
+ reqURL, err := url.Parse(urlStr)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, &APIError{Err: err, Message: fmt.Sprintf("invalid API URL: %s", urlStr)}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var bodyReader io.Reader
|
|
|
|
|
+ if config.body != nil {
|
|
|
|
|
+ bodyReader = bytes.NewReader(config.body)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ bodyReader = http.NoBody
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // timeout this request after 20 seconds
|
|
|
|
|
+ ctx, cancel := context.WithTimeout(context.Background(), HTTPTimeoutDuration)
|
|
|
|
|
+ defer cancel()
|
|
|
|
|
+
|
|
|
|
|
+ req, err := http.NewRequestWithContext(ctx, config.method, reqURL.String(), bodyReader)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, &APIError{Err: err, Message: "unable to form HTTP request"}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ req.Header.Set("content-type", "application/json")
|
|
|
|
|
+ req.Header.Set("user-agent", c.UserAgent)
|
|
|
|
|
+ req.Header.Set("api_key", c.OnboardbaseAPIKey)
|
|
|
|
|
+
|
|
|
|
|
+ for key, value := range config.headers {
|
|
|
|
|
+ req.Header.Set(key, value)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ query := req.URL.Query()
|
|
|
|
|
+ for key, value := range config.params {
|
|
|
|
|
+ query.Add(key, value)
|
|
|
|
|
+ }
|
|
|
|
|
+ req.URL.RawQuery = query.Encode()
|
|
|
|
|
+
|
|
|
|
|
+ r, err := c.httpClient.Do(req)
|
|
|
|
|
+
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, &APIError{Err: err, Message: "unable to load response"}
|
|
|
|
|
+ }
|
|
|
|
|
+ defer r.Body.Close()
|
|
|
|
|
+
|
|
|
|
|
+ bodyResponse, err := io.ReadAll(r.Body)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, &APIError{Err: err, Message: "unable to read entire response body"}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ response := &apiResponse{HTTPResponse: r, Body: bodyResponse}
|
|
|
|
|
+ success := isSuccess(r.StatusCode)
|
|
|
|
|
+
|
|
|
|
|
+ if !success {
|
|
|
|
|
+ return handlePerformRequestFailure(response)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if success && err != nil {
|
|
|
|
|
+ return nil, &APIError{Err: err, Message: "unable to load data from successful response"}
|
|
|
|
|
+ }
|
|
|
|
|
+ return response, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func handlePerformRequestFailure(response *apiResponse) (*apiResponse, *APIError) {
|
|
|
|
|
+ if contentType := response.HTTPResponse.Header.Get("content-type"); strings.HasPrefix(contentType, "application/json") {
|
|
|
|
|
+ var errResponse apiErrorResponse
|
|
|
|
|
+ err := json.Unmarshal(response.Body, &errResponse)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return response, &APIError{Err: err, Message: "unable to unmarshal error JSON payload"}
|
|
|
|
|
+ }
|
|
|
|
|
+ return response, &APIError{Err: nil, Message: strings.Join(errResponse.Messages, "\n")}
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil, &APIError{Err: fmt.Errorf("%d status code; %d bytes", response.HTTPResponse.StatusCode, len(response.Body)), Message: "unable to load response"}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func isSuccess(statusCode int) bool {
|
|
|
|
|
+ return (statusCode >= 200 && statusCode <= 299) || (statusCode >= 300 && statusCode <= 399)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (e *APIError) Error() string {
|
|
|
|
|
+ message := fmt.Sprintf("Onboardbase API Client Error: %s", e.Message)
|
|
|
|
|
+ if underlyingError := e.Err; underlyingError != nil {
|
|
|
|
|
+ message = fmt.Sprintf("%s\n%s", message, underlyingError.Error())
|
|
|
|
|
+ }
|
|
|
|
|
+ if e.Data != "" {
|
|
|
|
|
+ message = fmt.Sprintf("%s\nData: %s", message, e.Data)
|
|
|
|
|
+ }
|
|
|
|
|
+ return message
|
|
|
|
|
+}
|