mirror of
https://github.com/external-secrets/external-secrets.git
synced 2024-12-15 17:51:01 +00:00
f01e13f21b
* Add Doppler provider Signed-off-by: Ryan Blunden <ryan.blunden@doppler.com>
348 lines
8.7 KiB
Go
348 lines
8.7 KiB
Go
/*
|
|
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"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type DopplerClient struct {
|
|
baseURL *url.URL
|
|
DopplerToken string
|
|
VerifyTLS bool
|
|
UserAgent string
|
|
}
|
|
|
|
type queryParams map[string]string
|
|
|
|
type headers map[string]string
|
|
|
|
type httpRequestBody []byte
|
|
|
|
type Secrets map[string]string
|
|
|
|
type RawSecrets map[string]*interface{}
|
|
|
|
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 {
|
|
Name string
|
|
Project string
|
|
Config string
|
|
}
|
|
|
|
type SecretsRequest struct {
|
|
Project string
|
|
Config string
|
|
NameTransformer string
|
|
Format string
|
|
ETag string // Specifying an ETag implies that the caller has implemented response caching
|
|
}
|
|
|
|
type UpdateSecretsRequest struct {
|
|
Secrets RawSecrets `json:"secrets,omitempty"`
|
|
Project string `json:"project,omitempty"`
|
|
Config string `json:"config,omitempty"`
|
|
}
|
|
|
|
type secretResponseBody struct {
|
|
Name string `json:"name,omitempty"`
|
|
Value struct {
|
|
Raw *string `json:"raw"`
|
|
Computed *string `json:"computed"`
|
|
} `json:"value,omitempty"`
|
|
Messages *[]string `json:"messages,omitempty"`
|
|
Success bool `json:"success"`
|
|
}
|
|
|
|
type SecretResponse struct {
|
|
Name string
|
|
Value string
|
|
}
|
|
|
|
type SecretsResponse struct {
|
|
Secrets Secrets
|
|
Body []byte
|
|
Modified bool
|
|
ETag string
|
|
}
|
|
|
|
func NewDopplerClient(dopplerToken string) (*DopplerClient, error) {
|
|
client := &DopplerClient{
|
|
DopplerToken: dopplerToken,
|
|
VerifyTLS: true,
|
|
UserAgent: "doppler-external-secrets",
|
|
}
|
|
|
|
if err := client.SetBaseURL("https://api.doppler.com"); err != nil {
|
|
return nil, &APIError{Err: err, Message: "setting base URL failed"}
|
|
}
|
|
|
|
return client, nil
|
|
}
|
|
|
|
func (c *DopplerClient) BaseURL() *url.URL {
|
|
u := *c.baseURL
|
|
return &u
|
|
}
|
|
|
|
func (c *DopplerClient) SetBaseURL(urlStr string) error {
|
|
baseURL, err := url.Parse(strings.TrimSuffix(urlStr, "/"))
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if baseURL.Scheme == "" {
|
|
baseURL.Scheme = "https"
|
|
}
|
|
|
|
c.baseURL = baseURL
|
|
return nil
|
|
}
|
|
|
|
func (c *DopplerClient) Authenticate() error {
|
|
// Choose projects as a lightweight endpoint for testing authentication
|
|
if _, err := c.performRequest("/v3/projects", "GET", headers{}, queryParams{}, httpRequestBody{}); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *DopplerClient) GetSecret(request SecretRequest) (*SecretResponse, error) {
|
|
params := request.buildQueryParams(request.Name)
|
|
response, err := c.performRequest("/v3/configs/config/secret", "GET", headers{}, params, 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)}
|
|
}
|
|
|
|
if data.Value.Computed == nil {
|
|
return nil, &APIError{Message: fmt.Sprintf("secret '%s' not found", request.Name)}
|
|
}
|
|
|
|
return &SecretResponse{Name: data.Name, Value: *data.Value.Computed}, nil
|
|
}
|
|
|
|
// GetSecrets should only have an ETag supplied if Secrets are cached as SecretsResponse.Secrets will be nil if 304 (not modified) returned.
|
|
func (c *DopplerClient) GetSecrets(request SecretsRequest) (*SecretsResponse, error) {
|
|
headers := headers{}
|
|
if request.ETag != "" {
|
|
headers["if-none-match"] = request.ETag
|
|
}
|
|
if request.Format != "" && request.Format != "json" {
|
|
headers["accept"] = "text/plain"
|
|
}
|
|
|
|
params := request.buildQueryParams()
|
|
response, apiErr := c.performRequest("/v3/configs/config/secrets/download", "GET", headers, params, httpRequestBody{})
|
|
if apiErr != nil {
|
|
return nil, apiErr
|
|
}
|
|
|
|
if response.HTTPResponse.StatusCode == 304 {
|
|
return &SecretsResponse{Modified: false, Secrets: nil, ETag: request.ETag}, nil
|
|
}
|
|
|
|
eTag := response.HTTPResponse.Header.Get("etag")
|
|
|
|
// Format defeats JSON parsing
|
|
if request.Format != "" {
|
|
return &SecretsResponse{Modified: true, Body: response.Body, ETag: eTag}, nil
|
|
}
|
|
|
|
var secrets Secrets
|
|
if err := json.Unmarshal(response.Body, &secrets); err != nil {
|
|
return nil, &APIError{Err: err, Message: "unable to unmarshal secrets payload"}
|
|
}
|
|
return &SecretsResponse{Modified: true, Secrets: secrets, Body: response.Body, ETag: eTag}, nil
|
|
}
|
|
|
|
func (c *DopplerClient) UpdateSecrets(request UpdateSecretsRequest) error {
|
|
body, jsonErr := json.Marshal(request)
|
|
if jsonErr != nil {
|
|
return &APIError{Err: jsonErr, Message: "unable to unmarshal update secrets payload"}
|
|
}
|
|
_, err := c.performRequest("/v3/configs/config/secrets", "POST", headers{}, queryParams{}, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *SecretRequest) buildQueryParams(name string) queryParams {
|
|
params := queryParams{}
|
|
params["name"] = name
|
|
|
|
if r.Project != "" {
|
|
params["project"] = r.Project
|
|
}
|
|
|
|
if r.Config != "" {
|
|
params["config"] = r.Config
|
|
}
|
|
|
|
return params
|
|
}
|
|
|
|
func (r *SecretsRequest) buildQueryParams() queryParams {
|
|
params := queryParams{}
|
|
|
|
if r.Project != "" {
|
|
params["project"] = r.Project
|
|
}
|
|
|
|
if r.Config != "" {
|
|
params["config"] = r.Config
|
|
}
|
|
|
|
if r.NameTransformer != "" {
|
|
params["name_transformer"] = r.NameTransformer
|
|
}
|
|
|
|
if r.Format != "" {
|
|
params["format"] = r.Format
|
|
}
|
|
|
|
return params
|
|
}
|
|
|
|
func (c *DopplerClient) performRequest(path, method string, headers headers, params queryParams, body httpRequestBody) (*apiResponse, error) {
|
|
urlStr := c.BaseURL().String() + 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 body != nil {
|
|
bodyReader = bytes.NewReader(body)
|
|
} else {
|
|
bodyReader = http.NoBody
|
|
}
|
|
|
|
req, err := http.NewRequest(method, reqURL.String(), bodyReader)
|
|
if err != nil {
|
|
return nil, &APIError{Err: err, Message: "unable to form HTTP request"}
|
|
}
|
|
|
|
if method == "POST" && req.Header.Get("content-type") == "" {
|
|
req.Header.Set("content-type", "application/json")
|
|
}
|
|
|
|
if req.Header.Get("accept") == "" {
|
|
req.Header.Set("accept", "application/json")
|
|
}
|
|
req.Header.Set("user-agent", c.UserAgent)
|
|
req.SetBasicAuth(c.DopplerToken, "")
|
|
|
|
for key, value := range headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
|
|
query := req.URL.Query()
|
|
for key, value := range params {
|
|
query.Add(key, value)
|
|
}
|
|
req.URL.RawQuery = query.Encode()
|
|
|
|
httpClient := &http.Client{Timeout: 10 * time.Second}
|
|
|
|
tlsConfig := &tls.Config{
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
|
|
if !c.VerifyTLS {
|
|
tlsConfig.InsecureSkipVerify = true
|
|
}
|
|
|
|
httpClient.Transport = &http.Transport{
|
|
DisableKeepAlives: true,
|
|
TLSClientConfig: tlsConfig,
|
|
}
|
|
|
|
r, err := 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 &apiResponse{HTTPResponse: r, Body: nil}, &APIError{Err: err, Message: "unable to read entire response body"}
|
|
}
|
|
|
|
response := &apiResponse{HTTPResponse: r, Body: bodyResponse}
|
|
success := isSuccess(r.StatusCode)
|
|
|
|
if !success {
|
|
if contentType := r.Header.Get("content-type"); strings.HasPrefix(contentType, "application/json") {
|
|
var errResponse apiErrorResponse
|
|
err := json.Unmarshal(bodyResponse, &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", r.StatusCode, len(bodyResponse)), Message: "unable to load response"}
|
|
}
|
|
|
|
if success && err != nil {
|
|
return nil, &APIError{Err: err, Message: "unable to load data from successful response"}
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func isSuccess(statusCode int) bool {
|
|
return (statusCode >= 200 && statusCode <= 299) || (statusCode >= 300 && statusCode <= 399)
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
message := fmt.Sprintf("Doppler 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
|
|
}
|