ceph-csi/vendor/github.com/IBM/keyprotect-go-client/kp.go

511 lines
14 KiB
Go
Raw Normal View History

// Copyright 2019 IBM Corp.
//
// 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.
// keyprotect-go-client is a Go client library for interacting with the IBM KeyProtect service.
package kp
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
rhttp "github.com/hashicorp/go-retryablehttp"
"github.com/IBM/keyprotect-go-client/iam"
)
const (
// DefaultBaseURL ...
DefaultBaseURL = "https://us-south.kms.cloud.ibm.com"
// DefaultTokenURL ..
DefaultTokenURL = iam.IAMTokenURL
// VerboseNone ...
VerboseNone = 0
// VerboseBodyOnly ...
VerboseBodyOnly = 1
// VerboseAll ...
VerboseAll = 2
// VerboseFailOnly ...
VerboseFailOnly = 3
// VerboseAllNoRedact ...
VerboseAllNoRedact = 4
authContextKey ContextKey = 0
defaultTimeout = 30 // in seconds.
)
var (
// RetryWaitMax is the maximum time to wait between HTTP retries
RetryWaitMax = 30 * time.Second
// RetryMax is the max number of attempts to retry for failed HTTP requests
RetryMax = 4
cidCtxKey = ctxKey("X-Correlation-Id")
)
type ctxKey string
// ClientConfig ...
type ClientConfig struct {
BaseURL string
Authorization string // The IBM Cloud (Bluemix) access token
APIKey string // Service ID API key, can be used instead of an access token
TokenURL string // The URL used to get an access token from the API key
InstanceID string // The IBM Cloud (Bluemix) instance ID that identifies your Key Protect service instance.
KeyRing string // The ID of the target Key Ring the key is associated with. It is optional but recommended for better performance.
Verbose int // See verbose values above
Timeout float64 // KP request timeout in seconds.
}
// DefaultTransport ...
func DefaultTransport() http.RoundTripper {
transport := &http.Transport{
DisableKeepAlives: true,
MaxIdleConnsPerHost: -1,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: false,
},
}
return transport
}
// API is deprecated. Use Client instead.
type API = Client
// Client holds configuration and auth information to interact with KeyProtect.
// It is expected that one of these is created per KeyProtect service instance/credential pair.
type Client struct {
URL *url.URL
HttpClient http.Client
Dump Dump
Config ClientConfig
Logger Logger
tokenSource iam.TokenSource
}
// New creates and returns a Client without logging.
func New(config ClientConfig, transport http.RoundTripper) (*Client, error) {
return NewWithLogger(config, transport, nil)
}
// NewWithLogger creates and returns a Client with logging. The
// error value will be non-nil if the config is invalid.
func NewWithLogger(config ClientConfig, transport http.RoundTripper, logger Logger) (*Client, error) {
if transport == nil {
transport = DefaultTransport()
}
if logger == nil {
logger = NewLogger(func(args ...interface{}) {
fmt.Println(args...)
})
}
if config.Verbose > len(dumpers)-1 || config.Verbose < 0 {
return nil, errors.New("verbose value is out of range")
}
if config.Timeout == 0 {
config.Timeout = defaultTimeout
}
keysURL := fmt.Sprintf("%s/api/v2/", config.BaseURL)
u, err := url.Parse(keysURL)
if err != nil {
return nil, err
}
ts := iam.CredentialFromAPIKey(config.APIKey)
if config.TokenURL != "" {
ts.TokenURL = config.TokenURL
}
c := &Client{
URL: u,
HttpClient: http.Client{
Timeout: time.Duration(config.Timeout * float64(time.Second)),
Transport: transport,
},
Dump: dumpers[config.Verbose],
Config: config,
Logger: logger,
tokenSource: ts,
}
return c, nil
}
func (c *Client) newRequest(method, path string, body interface{}) (*http.Request, error) {
u, err := c.URL.Parse(path)
if err != nil {
return nil, err
}
var reqBody []byte
var buf io.Reader
if body != nil {
reqBody, err = json.Marshal(body)
if err != nil {
return nil, err
}
buf = bytes.NewBuffer(reqBody)
}
request, err := http.NewRequest(method, u.String(), buf)
if err != nil {
return nil, err
}
request.Header.Set("accept", "application/json")
return request, nil
}
type reason struct {
Code string
Message string
Status int
MoreInfo string
}
func (r reason) String() string {
if r.MoreInfo != "" {
return fmt.Sprintf("%s: %s - FOR_MORE_INFO_REFER: %s", r.Code, r.Message, r.MoreInfo)
}
return fmt.Sprintf("%s: %s", r.Code, r.Message)
}
type Error struct {
URL string // URL of request that resulted in this error
StatusCode int // HTTP error code from KeyProtect service
Message string // error message from KeyProtect service
BodyContent []byte // raw body content if more inspection is needed
CorrelationID string // string value of a UUID that uniquely identifies the request to KeyProtect
Reasons []reason // collection of reason types containing detailed error messages
}
// Error returns correlation id and error message string
func (e Error) Error() string {
var extraVars string
if e.Reasons != nil && len(e.Reasons) > 0 {
extraVars = fmt.Sprintf(", reasons='%s'", e.Reasons)
}
return fmt.Sprintf("kp.Error: correlation_id='%v', msg='%s'%s", e.CorrelationID, e.Message, extraVars)
}
// URLError wraps an error from client.do() calls with a correlation ID from KeyProtect
type URLError struct {
Err error
CorrelationID string
}
func (e URLError) Error() string {
return fmt.Sprintf(
"error during request to KeyProtect correlation_id='%s': %s", e.CorrelationID, e.Err.Error())
}
func (c *Client) do(ctx context.Context, req *http.Request, res interface{}) (*http.Response, error) {
acccesToken, err := c.getAccessToken(ctx)
if err != nil {
return nil, err
}
// retrieve the correlation id from the context. If not present, then a UUID will be
// generated for the correlation ID and feed it into the request
// KeyProtect will use this when it is set on a request header rather than generating its
// own inside the service
// if not present, we generate our own here because a connection error might actually
// mean the request doesn't make it server side, so having a correlation ID locally helps
// us know that when comparing with server side logs.
corrID := c.getCorrelationID(ctx)
req.Header.Set("bluemix-instance", c.Config.InstanceID)
req.Header.Set("authorization", acccesToken)
req.Header.Set("correlation-id", corrID)
if c.Config.KeyRing != "" {
req.Header.Set("x-kms-key-ring", c.Config.KeyRing)
}
// set request up to be retryable on 500-level http codes and client errors
retryableClient := getRetryableClient(&c.HttpClient)
retryableRequest, err := rhttp.FromRequest(req)
if err != nil {
return nil, err
}
response, err := retryableClient.Do(retryableRequest.WithContext(ctx))
if err != nil {
return nil, &URLError{err, corrID}
}
defer response.Body.Close()
resBody, err := ioutil.ReadAll(response.Body)
redact := []string{c.Config.APIKey, req.Header.Get("authorization")}
c.Dump(req, response, []byte{}, resBody, c.Logger, redact)
if err != nil {
return nil, err
}
type KPErrorMsg struct {
Message string `json:"errorMsg,omitempty"`
Reasons []reason
}
type KPError struct {
Resources []KPErrorMsg `json:"resources,omitempty"`
}
switch response.StatusCode {
case http.StatusCreated:
if len(resBody) != 0 {
if err := json.Unmarshal(resBody, res); err != nil {
return nil, err
}
}
case http.StatusOK:
if err := json.Unmarshal(resBody, res); err != nil {
return nil, err
}
case http.StatusNoContent:
default:
errMessage := string(resBody)
var reasons []reason
if strings.Contains(string(resBody), "errorMsg") {
kperr := KPError{}
json.Unmarshal(resBody, &kperr)
if len(kperr.Resources) > 0 && len(kperr.Resources[0].Message) > 0 {
errMessage = kperr.Resources[0].Message
reasons = kperr.Resources[0].Reasons
}
}
return nil, &Error{
URL: response.Request.URL.String(),
StatusCode: response.StatusCode,
Message: errMessage,
BodyContent: resBody,
CorrelationID: corrID,
Reasons: reasons,
}
}
return response, nil
}
// getRetryableClient returns a fully configured retryable HTTP client
func getRetryableClient(client *http.Client) *rhttp.Client {
// build base client with the library defaults and override as neeeded
rc := rhttp.NewClient()
rc.Logger = nil
rc.HTTPClient = client
rc.RetryWaitMax = RetryWaitMax
rc.RetryMax = RetryMax
rc.CheckRetry = kpCheckRetry
rc.ErrorHandler = rhttp.PassthroughErrorHandler
return rc
}
// kpCheckRetry will retry on connection errors, server errors, and 429s (rate limit)
func kpCheckRetry(ctx context.Context, resp *http.Response, err error) (bool, error) {
// do not retry on context.Canceled or context.DeadlineExceeded
if ctx.Err() != nil {
return false, ctx.Err()
}
if err != nil {
return true, err
}
// Retry on connection errors, 500+ errors (except 501 - not implemented), and 429 - too many requests
if resp.StatusCode == 0 || resp.StatusCode == 429 || (resp.StatusCode >= 500 && resp.StatusCode != 501) {
return true, nil
}
return false, nil
}
// ContextKey provides a type to auth context keys.
type ContextKey int
// NewContextWithAuth ...
func NewContextWithAuth(parent context.Context, auth string) context.Context {
return context.WithValue(parent, authContextKey, auth)
}
// getAccessToken returns the auth context from the given Context, or
// calls to the IAMTokenSource to retrieve an auth token.
func (c *Client) getAccessToken(ctx context.Context) (string, error) {
if ctx.Value(authContextKey) != nil {
return ctx.Value(authContextKey).(string), nil
}
if len(c.Config.Authorization) > 0 {
return c.Config.Authorization, nil
}
token, err := c.tokenSource.Token()
if err != nil {
return "", err
}
return fmt.Sprintf("%s %s", token.TokenType, token.AccessToken), nil
}
// getCorrelationId returns the correlation ID value from the given Context, or
// returns a new UUID if not present
func (c *Client) getCorrelationID(ctx context.Context) string {
corrID := GetCorrelationID(ctx)
if corrID == nil {
return uuid.New().String()
}
return corrID.String()
}
// NewContextWithCorrelationID retuns a context containing the UUID
func NewContextWithCorrelationID(ctx context.Context, uuid *uuid.UUID) context.Context {
return context.WithValue(ctx, cidCtxKey, uuid)
}
// GetCorrelationID returns the correlation ID from the context
func GetCorrelationID(ctx context.Context) *uuid.UUID {
if id := ctx.Value(cidCtxKey); id != nil {
return id.(*uuid.UUID)
}
return nil
}
func (c ctxKey) String() string {
return string(c)
}
// Logger writes when called.
type Logger interface {
Info(...interface{})
}
type logger struct {
writer func(...interface{})
}
func (l *logger) Info(args ...interface{}) {
l.writer(args...)
}
func NewLogger(writer func(...interface{})) Logger {
return &logger{writer: writer}
}
var dumpers = []Dump{dumpNone, dumpBodyOnly, dumpAll, dumpFailOnly, dumpAllNoRedact}
// Dump writes various parts of an HTTP request and an HTTP response.
type Dump func(*http.Request, *http.Response, []byte, []byte, Logger, []string)
// Redact replaces various pieces of output.
type Redact func(string, []string) string
// dumpFailOnly calls dumpAll when the HTTP response isn't 200 (ok),
// 201 (created), or 204 (no content).
func dumpFailOnly(req *http.Request, rsp *http.Response, reqBody, resBody []byte, log Logger, redactStrings []string) {
switch rsp.StatusCode {
case http.StatusOK, http.StatusCreated, http.StatusNoContent:
return
}
dumpAll(req, rsp, reqBody, resBody, log, redactStrings)
}
// dumpAll dumps the HTTP request and the HTTP response body.
func dumpAll(req *http.Request, rsp *http.Response, reqBody, resBody []byte, log Logger, redactStrings []string) {
dumpRequest(req, rsp, log, redactStrings, redact)
dumpBody(reqBody, resBody, log, redactStrings, redact)
}
// dumpAllNoRedact dumps the HTTP request and HTTP response body without redaction.
func dumpAllNoRedact(req *http.Request, rsp *http.Response, reqBody, resBody []byte, log Logger, redactStrings []string) {
dumpRequest(req, rsp, log, redactStrings, noredact)
dumpBody(reqBody, resBody, log, redactStrings, noredact)
}
// dumpBodyOnly dumps the HTTP response body.
func dumpBodyOnly(req *http.Request, rsp *http.Response, reqBody, resBody []byte, log Logger, redactStrings []string) {
dumpBody(reqBody, resBody, log, redactStrings, redact)
}
// dumpNone does nothing.
func dumpNone(req *http.Request, rsp *http.Response, reqBody, resBody []byte, log Logger, redactStrings []string) {
}
// dumpRequest dumps the HTTP request.
func dumpRequest(req *http.Request, rsp *http.Response, log Logger, redactStrings []string, redact Redact) {
// log.Info(redact(fmt.Sprint(req), redactStrings))
// log.Info(redact(fmt.Sprint(rsp), redactStrings))
}
// dumpBody dumps the HTTP response body with redactions.
func dumpBody(reqBody, resBody []byte, log Logger, redactStrings []string, redact Redact) {
// log.Info(string(redact(string(reqBody), redactStrings)))
// Redact the access token and refresh token if it shows up in the reponnse body. This will happen
// when using an API Key
var auth iam.Token
if strings.Contains(string(resBody), "access_token") {
err := json.Unmarshal(resBody, &auth)
if err != nil {
log.Info(err)
}
redactStrings = append(redactStrings, auth.AccessToken)
redactStrings = append(redactStrings, auth.RefreshToken)
}
// log.Info(string(redact(string(resBody), redactStrings)))
}
// redact replaces substrings within the given string.
func redact(s string, redactStrings []string) string {
if len(redactStrings) < 1 {
return s
}
var a []string
for _, s1 := range redactStrings {
if s1 != "" {
a = append(a, s1)
a = append(a, "***Value redacted***")
}
}
r := strings.NewReplacer(a...)
return r.Replace(s)
}
// noredact does not perform redaction, and returns the given string.
func noredact(s string, redactStrings []string) string {
return s
}