package vault import ( "encoding/json" "fmt" "path" "strconv" "strings" "time" "github.com/SAP/jenkins-library/pkg/log" "github.com/hashicorp/vault/api" ) // Client handles communication with Vault type Client struct { lClient logicalClient config *Config } // Config contains the vault client configuration type Config struct { *api.Config AppRoleMountPoint string Namespace string } // logicalClient interface for mocking type logicalClient interface { Read(string) (*api.Secret, error) Write(string, map[string]interface{}) (*api.Secret, error) } // NewClient instantiates a Client and sets the specified token func NewClient(config *Config, token string) (Client, error) { if config == nil { config = &Config{Config: api.DefaultConfig()} } client, err := api.NewClient(config.Config) if err != nil { return Client{}, err } if config.Namespace != "" { client.SetNamespace(config.Namespace) } client.SetToken(token) log.Entry().Debugf("Login to vault %s in namespace %s successfull", config.Address, config.Namespace) return Client{client.Logical(), config}, nil } // NewClientWithAppRole instantiates a new client and obtains a token via the AppRole auth method func NewClientWithAppRole(config *Config, roleID, secretID string) (Client, error) { if config == nil { config = &Config{Config: api.DefaultConfig()} } if config.AppRoleMountPoint == "" { config.AppRoleMountPoint = "auth/approle" } client, err := api.NewClient(config.Config) if err != nil { return Client{}, err } if config.Namespace != "" { client.SetNamespace(config.Namespace) } log.Entry().Debug("Using approle login") result, err := client.Logical().Write(path.Join(config.AppRoleMountPoint, "/login"), map[string]interface{}{ "role_id": roleID, "secret_id": secretID, }) if err != nil { return Client{}, err } authInfo := result.Auth if authInfo == nil || authInfo.ClientToken == "" { return Client{}, fmt.Errorf("Could not obtain token from approle with role_id %s", roleID) } return NewClient(config, authInfo.ClientToken) } // GetSecret uses the given path to fetch a secret from vault func (v Client) GetSecret(path string) (*api.Secret, error) { path = sanitizePath(path) c := v.lClient secret, err := c.Read(path) if err != nil { return nil, err } return secret, nil } // GetKvSecret reads secret from the KV engine. // It Automatically transforms the logical path to the HTTP API Path for the corresponding KV Engine version func (v Client) GetKvSecret(path string) (map[string]string, error) { path = sanitizePath(path) mountpath, version, err := v.getKvInfo(path) if err != nil { return nil, err } if version == 2 { path = addPrefixToKvPath(path, mountpath, "data") } else if version != 1 { return nil, fmt.Errorf("KV Engine in version %d is currently not supported", version) } secret, err := v.GetSecret(path) if secret == nil || err != nil { return nil, err } var rawData interface{} switch version { case 1: rawData = secret.Data case 2: var ok bool rawData, ok = secret.Data["data"] if !ok { return nil, fmt.Errorf("Missing 'data' field in response: %v", rawData) } } data, ok := rawData.(map[string]interface{}) if !ok { return nil, fmt.Errorf("Excpected 'data' field to be a map[string]interface{} but got %T instead", rawData) } secretData := make(map[string]string, len(data)) for k, v := range data { valueStr, ok := v.(string) if ok { secretData[k] = valueStr } } return secretData, nil } // WriteKvSecret writes secret to kv engine func (v Client) WriteKvSecret(path string, newSecret map[string]string) error { oldSecret, err := v.GetKvSecret(path) if err != nil { return err } secret := make(map[string]interface{}, len(oldSecret)) for k, v := range oldSecret { secret[k] = v } for k, v := range newSecret { secret[k] = v } path = sanitizePath(path) mountpath, version, err := v.getKvInfo(path) if err != nil { return err } if version == 2 { path = addPrefixToKvPath(path, mountpath, "data") secret = map[string]interface{}{"data": secret} } else if version != 1 { return fmt.Errorf("KV Engine in version %d is currently not supported", version) } _, err = v.lClient.Write(path, secret) return err } // GenerateNewAppRoleSecret creates a new secret-id func (v *Client) GenerateNewAppRoleSecret(secretID, appRoleName string) (string, error) { appRolePath := v.getAppRolePath(appRoleName) secretIDData, err := v.lookupSecretID(secretID, appRolePath) if err != nil { return "", err } reqPath := sanitizePath(path.Join(appRolePath, "/secret-id")) // we preserve metadata which was attached to the secret-id json, err := json.Marshal(secretIDData["metadata"]) if err != nil { return "", err } secret, err := v.lClient.Write(reqPath, map[string]interface{}{ "metadata": string(json), }) if err != nil { return "", err } if secret == nil || secret.Data == nil { return "", fmt.Errorf("Could not generate new approle secret-id for approle path %s", reqPath) } secretIDRaw, ok := secret.Data["secret_id"] if !ok { return "", fmt.Errorf("Vault response for path %s did not contain a new secret-id", reqPath) } newSecretID, ok := secretIDRaw.(string) if !ok { return "", fmt.Errorf("New secret-id from approle path %s has an unexpected type %T expected 'string'", reqPath, secretIDRaw) } return newSecretID, nil } // GetAppRoleSecretIDTtl returns the remaining time until the given secret-id expires func (v *Client) GetAppRoleSecretIDTtl(secretID, roleName string) (time.Duration, error) { appRolePath := v.getAppRolePath(roleName) data, err := v.lookupSecretID(secretID, appRolePath) if err != nil { return 0, err } if data == nil || data["expiration_time"] == nil { return 0, fmt.Errorf("Could not load secret-id information from path %s", appRolePath) } expiration, ok := data["expiration_time"].(string) if !ok || expiration == "" { return 0, fmt.Errorf("Could not handle get expiration time for secret-id from path %s", appRolePath) } expirationDate, err := time.Parse(time.RFC3339, expiration) if err != nil { return 0, err } ttl := expirationDate.Sub(time.Now()) if ttl < 0 { return 0, nil } return ttl, nil } // RevokeToken revokes the token which is currently used. // The client can't be used anymore after this function was called. func (v Client) RevokeToken() error { _, err := v.lClient.Write("auth/token/revoke-self", map[string]interface{}{}) return err } // MustRevokeToken same as RevokeToken but the programm is terminated with an error if this fails. // Should be used in defer statements only. func (v Client) MustRevokeToken() { if err := v.RevokeToken(); err != nil { log.Entry().WithError(err).Fatal("Could not revoke token") } } // GetAppRoleName returns the AppRole role name which was used to authenticate. // Returns "" when AppRole authentication wasn't used func (v *Client) GetAppRoleName() (string, error) { const lookupPath = "auth/token/lookup-self" secret, err := v.GetSecret(lookupPath) if err != nil { return "", err } if secret.Data == nil { return "", fmt.Errorf("Could not lookup token information: %s", lookupPath) } meta, ok := secret.Data["meta"] if !ok { return "", fmt.Errorf("Token info did not contain metadata %s", lookupPath) } metaMap, ok := meta.(map[string]interface{}) if !ok { return "", fmt.Errorf("Token info field 'meta' is not a map: %s", lookupPath) } roleName := metaMap["role_name"] if roleName == nil { return "", nil } roleNameStr, ok := roleName.(string) if !ok { // when approle authentication is not used vault admins can use the role_name field with other type // so no error in this case return "", nil } return roleNameStr, nil } // SetAppRoleMountPoint sets the path under which the approle auth backend is mounted func (v *Client) SetAppRoleMountPoint(appRoleMountpoint string) { v.config.AppRoleMountPoint = appRoleMountpoint } func (v *Client) getAppRolePath(roleName string) string { appRoleMountPoint := v.config.AppRoleMountPoint if appRoleMountPoint == "" { appRoleMountPoint = "auth/approle" } return path.Join(appRoleMountPoint, "role", roleName) } func sanitizePath(path string) string { path = strings.TrimSpace(path) path = strings.TrimPrefix(path, "/") path = strings.TrimSuffix(path, "/") return path } func addPrefixToKvPath(p, mountPath, apiPrefix string) string { switch { case p == mountPath, p == strings.TrimSuffix(mountPath, "/"): return path.Join(mountPath, apiPrefix) default: p = strings.TrimPrefix(p, mountPath) return path.Join(mountPath, apiPrefix, p) } } func (v *Client) getKvInfo(path string) (string, int, error) { secret, err := v.GetSecret("sys/internal/ui/mounts/" + path) if err != nil { return "", 0, err } if secret == nil { return "", 0, fmt.Errorf("Failed to get version and engine mountpoint for path: %s", path) } var mountPath string if mountPathRaw, ok := secret.Data["path"]; ok { mountPath = mountPathRaw.(string) } options := secret.Data["options"] if options == nil { return mountPath, 1, nil } versionRaw := options.(map[string]interface{})["version"] if versionRaw == nil { return mountPath, 1, nil } version := versionRaw.(string) if version == "" { return mountPath, 1, nil } vNumber, err := strconv.Atoi(version) if err != nil { return mountPath, 0, err } return mountPath, vNumber, nil } func (v *Client) lookupSecretID(secretID, appRolePath string) (map[string]interface{}, error) { reqPath := sanitizePath(path.Join(appRolePath, "/secret-id/lookup")) secret, err := v.lClient.Write(reqPath, map[string]interface{}{ "secret_id": secretID, }) if err != nil { return nil, err } return secret.Data, nil }