diff --git a/CHANGELOG.md b/CHANGELOG.md index 838ac76fe4..98666ff7d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,20 @@ ## 0.10.1 (Unreleased) +FEATURES: + + * X-Forwarded-For support: `X-Forwarded-For` headers can now be used to set + the client IP seen by Vault. See the [TCP listener configuration + page](https://www.vaultproject.io/docs/configuration/listener/tcp.html) for + details. + IMPROVEMENTS: + * auth/token: Add to the token lookup response, the policies inherited due to + identity associations [GH-4366] + * core: Add X-Forwarded-For support [GH-4380] * identity: Add the ability to disable an entity. Disabling an entity does not revoke associated tokens, but while the entity is disabled they cannot be used. [GH-4353] - * auth/token: Add to the token lookup response, the policies inherited due to - identity associations [GH-4366] BUG FIXES: diff --git a/builtin/logical/mssql/path_creds_create.go b/builtin/logical/mssql/path_creds_create.go index 7e26937016..1a954c87b4 100644 --- a/builtin/logical/mssql/path_creds_create.go +++ b/builtin/logical/mssql/path_creds_create.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/hashicorp/go-uuid" + "github.com/hashicorp/vault/helper/dbtxn" "github.com/hashicorp/vault/helper/strutil" "github.com/hashicorp/vault/logical" "github.com/hashicorp/vault/logical/framework" @@ -90,15 +91,11 @@ func (b *backend) pathCredsCreateRead(ctx context.Context, req *logical.Request, continue } - stmt, err := tx.Prepare(Query(query, map[string]string{ + m := map[string]string{ "name": username, "password": password, - })) - if err != nil { - return nil, err } - defer stmt.Close() - if _, err := stmt.Exec(); err != nil { + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { return nil, err } } diff --git a/builtin/logical/mssql/secret_creds.go b/builtin/logical/mssql/secret_creds.go index ce93643eec..5edac67be5 100644 --- a/builtin/logical/mssql/secret_creds.go +++ b/builtin/logical/mssql/secret_creds.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/hashicorp/errwrap" + "github.com/hashicorp/vault/helper/dbtxn" "github.com/hashicorp/vault/logical" "github.com/hashicorp/vault/logical/framework" ) @@ -130,16 +131,11 @@ func (b *backend) secretCredsRevoke(ctx context.Context, req *logical.Request, d // many permissions as possible right now var lastStmtError error for _, query := range revokeStmts { - stmt, err := db.Prepare(query) - if err != nil { + + if err := dbtxn.ExecuteDBQuery(ctx, db, nil, query); err != nil { lastStmtError = err continue } - defer stmt.Close() - _, err = stmt.Exec() - if err != nil { - lastStmtError = err - } } // can't drop if not all database users are dropped diff --git a/builtin/logical/mysql/path_role_create.go b/builtin/logical/mysql/path_role_create.go index ae184bdb34..135587575d 100644 --- a/builtin/logical/mysql/path_role_create.go +++ b/builtin/logical/mysql/path_role_create.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/hashicorp/go-uuid" + "github.com/hashicorp/vault/helper/dbtxn" "github.com/hashicorp/vault/helper/strutil" "github.com/hashicorp/vault/logical" "github.com/hashicorp/vault/logical/framework" @@ -103,15 +104,11 @@ func (b *backend) pathRoleCreateRead(ctx context.Context, req *logical.Request, continue } - stmt, err := tx.Prepare(Query(query, map[string]string{ + m := map[string]string{ "name": username, "password": password, - })) - if err != nil { - return nil, err } - defer stmt.Close() - if _, err := stmt.Exec(); err != nil { + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { return nil, err } } diff --git a/builtin/logical/postgresql/path_role_create.go b/builtin/logical/postgresql/path_role_create.go index 7fed904667..113e3ab896 100644 --- a/builtin/logical/postgresql/path_role_create.go +++ b/builtin/logical/postgresql/path_role_create.go @@ -7,6 +7,7 @@ import ( "time" "github.com/hashicorp/go-uuid" + "github.com/hashicorp/vault/helper/dbtxn" "github.com/hashicorp/vault/helper/strutil" "github.com/hashicorp/vault/logical" "github.com/hashicorp/vault/logical/framework" @@ -106,16 +107,13 @@ func (b *backend) pathRoleCreateRead(ctx context.Context, req *logical.Request, continue } - stmt, err := tx.Prepare(Query(query, map[string]string{ + m := map[string]string{ "name": username, "password": password, "expiration": expiration, - })) - if err != nil { - return nil, err } - defer stmt.Close() - if _, err := stmt.Exec(); err != nil { + + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { return nil, err } } diff --git a/builtin/logical/postgresql/secret_creds.go b/builtin/logical/postgresql/secret_creds.go index 87748d1ff9..d00beacb65 100644 --- a/builtin/logical/postgresql/secret_creds.go +++ b/builtin/logical/postgresql/secret_creds.go @@ -8,6 +8,7 @@ import ( "time" "github.com/hashicorp/errwrap" + "github.com/hashicorp/vault/helper/dbtxn" "github.com/hashicorp/vault/helper/strutil" "github.com/hashicorp/vault/logical" "github.com/hashicorp/vault/logical/framework" @@ -211,14 +212,7 @@ func (b *backend) secretCredsRevoke(ctx context.Context, req *logical.Request, d // many permissions as possible right now var lastStmtError error for _, query := range revocationStmts { - stmt, err := db.Prepare(query) - if err != nil { - lastStmtError = err - continue - } - defer stmt.Close() - _, err = stmt.Exec() - if err != nil { + if err := dbtxn.ExecuteDBQuery(ctx, db, nil, query); err != nil { lastStmtError = err } } @@ -258,15 +252,10 @@ func (b *backend) secretCredsRevoke(ctx context.Context, req *logical.Request, d continue } - stmt, err := tx.Prepare(Query(query, map[string]string{ + m := map[string]string{ "name": username, - })) - if err != nil { - return nil, err } - defer stmt.Close() - - if _, err := stmt.Exec(); err != nil { + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { return nil, err } } diff --git a/command/server.go b/command/server.go index cb5bd9b7b5..630f9aada7 100644 --- a/command/server.go +++ b/command/server.go @@ -32,6 +32,7 @@ import ( "github.com/hashicorp/errwrap" log "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-multierror" + sockaddr "github.com/hashicorp/go-sockaddr" "github.com/hashicorp/vault/audit" "github.com/hashicorp/vault/command/server" "github.com/hashicorp/vault/helper/gated-writer" @@ -92,6 +93,11 @@ type ServerCommand struct { flagTestVerifyOnly bool } +type ServerListener struct { + net.Listener + config map[string]interface{} +} + func (c *ServerCommand) Synopsis() string { return "Start a Vault server" } @@ -670,8 +676,8 @@ CLUSTER_SYNTHESIS_COMPLETE: clusterAddrs := []*net.TCPAddr{} // Initialize the listeners + lns := make([]ServerListener, 0, len(config.Listeners)) c.reloadFuncsLock.Lock() - lns := make([]net.Listener, 0, len(config.Listeners)) for i, lnConfig := range config.Listeners { ln, props, reloadFunc, err := server.NewListener(lnConfig.Type, lnConfig.Config, c.logGate, c.UI) if err != nil { @@ -679,7 +685,10 @@ CLUSTER_SYNTHESIS_COMPLETE: return 1 } - lns = append(lns, ln) + lns = append(lns, ServerListener{ + Listener: ln, + config: lnConfig.Config, + }) if reloadFunc != nil { relSlice := (*c.reloadFuncs)["listener|"+lnConfig.Type] @@ -738,7 +747,7 @@ CLUSTER_SYNTHESIS_COMPLETE: // Make sure we close all listeners from this point on listenerCloseFunc := func() { for _, ln := range lns { - ln.Close() + ln.Listener.Close() } } @@ -776,12 +785,10 @@ CLUSTER_SYNTHESIS_COMPLETE: return 0 } - handler := vaulthttp.Handler(core) - // This needs to happen before we first unseal, so before we trigger dev // mode if it's set core.SetClusterListenerAddrs(clusterAddrs) - core.SetClusterHandler(handler) + core.SetClusterHandler(vaulthttp.Handler(core)) err = core.UnsealWithStoredKeys(context.Background()) if err != nil { @@ -914,10 +921,23 @@ CLUSTER_SYNTHESIS_COMPLETE: // Initialize the HTTP servers for _, ln := range lns { + handler := vaulthttp.Handler(core) + + // We perform validation on the config earlier, we can just cast here + if _, ok := ln.config["x_forwarded_for_authorized_addrs"]; ok { + hopSkips := ln.config["x_forwarded_for_hop_skips"].(int) + authzdAddrs := ln.config["x_forwarded_for_authorized_addrs"].([]*sockaddr.SockAddrMarshaler) + rejectNotPresent := ln.config["x_forwarded_for_reject_not_present"].(bool) + rejectNonAuthz := ln.config["x_forwarded_for_reject_not_authorized"].(bool) + if len(authzdAddrs) > 0 { + handler = vaulthttp.WrapForwardedForHandler(handler, authzdAddrs, rejectNotPresent, rejectNonAuthz, hopSkips) + } + } + server := &http.Server{ Handler: handler, } - go server.Serve(ln) + go server.Serve(ln.Listener) } if newCoreError != nil { diff --git a/command/server/config.go b/command/server/config.go index b150b83bd8..21520ce094 100644 --- a/command/server/config.go +++ b/command/server/config.go @@ -761,6 +761,10 @@ func parseListeners(result *Config, list *ast.ObjectList) error { "address", "cluster_address", "endpoint", + "x_forwarded_for_authorized_addrs", + "x_forwarded_for_hop_skips", + "x_forwarded_for_reject_not_authorized", + "x_forwarded_for_reject_not_present", "infrastructure", "node_id", "proxy_protocol_behavior", diff --git a/command/server/listener_tcp.go b/command/server/listener_tcp.go index bf39615a69..201e124f3a 100644 --- a/command/server/listener_tcp.go +++ b/command/server/listener_tcp.go @@ -1,11 +1,15 @@ package server import ( + "fmt" "io" "net" + "strconv" "strings" "time" + "github.com/hashicorp/errwrap" + "github.com/hashicorp/vault/helper/parseutil" "github.com/hashicorp/vault/helper/reload" "github.com/mitchellh/cli" ) @@ -39,6 +43,57 @@ func tcpListenerFactory(config map[string]interface{}, _ io.Writer, ui cli.Ui) ( } props := map[string]string{"addr": addr} + + ffAllowedRaw, ffAllowedOK := config["x_forwarded_for_authorized_addrs"] + if ffAllowedOK { + ffAllowed, err := parseutil.ParseAddrs(ffAllowedRaw) + if err != nil { + return nil, nil, nil, errwrap.Wrapf("error parsing \"x_forwarded_for_authorized_addrs\": {{err}}", err) + } + props["x_forwarded_for_authorized_addrs"] = fmt.Sprintf("%v", ffAllowed) + config["x_forwarded_for_authorized_addrs"] = ffAllowed + } + + if ffHopsRaw, ok := config["x_forwarded_for_hop_skips"]; ok { + ffHops64, err := parseutil.ParseInt(ffHopsRaw) + if err != nil { + return nil, nil, nil, errwrap.Wrapf("error parsing \"x_forwarded_for_hop_skips\": {{err}}", err) + } + if ffHops64 < 0 { + return nil, nil, nil, fmt.Errorf("\"x_forwarded_for_hop_skips\" cannot be negative") + } + ffHops := int(ffHops64) + props["x_forwarded_for_hop_skips"] = strconv.Itoa(ffHops) + config["x_forwarded_for_hop_skips"] = ffHops + } else if ffAllowedOK { + props["x_forwarded_for_hop_skips"] = "0" + config["x_forwarded_for_hop_skips"] = int(0) + } + + if ffRejectNotPresentRaw, ok := config["x_forwarded_for_reject_not_present"]; ok { + ffRejectNotPresent, err := parseutil.ParseBool(ffRejectNotPresentRaw) + if err != nil { + return nil, nil, nil, errwrap.Wrapf("error parsing \"x_forwarded_for_reject_not_present\": {{err}}", err) + } + props["x_forwarded_for_reject_not_present"] = strconv.FormatBool(ffRejectNotPresent) + config["x_forwarded_for_reject_not_present"] = ffRejectNotPresent + } else if ffAllowedOK { + props["x_forwarded_for_reject_not_present"] = "true" + config["x_forwarded_for_reject_not_present"] = true + } + + if ffRejectNonAuthorizedRaw, ok := config["x_forwarded_for_reject_not_authorized"]; ok { + ffRejectNonAuthorized, err := parseutil.ParseBool(ffRejectNonAuthorizedRaw) + if err != nil { + return nil, nil, nil, errwrap.Wrapf("error parsing \"x_forwarded_for_reject_not_authorized\": {{err}}", err) + } + props["x_forwarded_for_reject_not_authorized"] = strconv.FormatBool(ffRejectNonAuthorized) + config["x_forwarded_for_reject_not_authorized"] = ffRejectNonAuthorized + } else if ffAllowedOK { + props["x_forwarded_for_reject_not_authorized"] = "true" + config["x_forwarded_for_reject_not_authorized"] = true + } + return listenerWrapTLS(ln, props, config, ui) } diff --git a/helper/dbtxn/dbtxn.go b/helper/dbtxn/dbtxn.go new file mode 100644 index 0000000000..3337bd97b2 --- /dev/null +++ b/helper/dbtxn/dbtxn.go @@ -0,0 +1,63 @@ +package dbtxn + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// ExecuteDBQuery handles executing one single statement, while properly releasing its resources. +// - ctx: Required +// - db: Required +// - config: Optional, may be nil +// - query: Required +func ExecuteDBQuery(ctx context.Context, db *sql.DB, params map[string]string, query string) error { + + parsedQuery := parseQuery(params, query) + + stmt, err := db.PrepareContext(ctx, parsedQuery) + if err != nil { + return err + } + defer stmt.Close() + + return execute(ctx, stmt) +} + +// ExecuteTxQuery handles executing one single statement, while properly releasing its resources. +// - ctx: Required +// - tx: Required +// - config: Optional, may be nil +// - query: Required +func ExecuteTxQuery(ctx context.Context, tx *sql.Tx, params map[string]string, query string) error { + + parsedQuery := parseQuery(params, query) + + stmt, err := tx.PrepareContext(ctx, parsedQuery) + if err != nil { + return err + } + defer stmt.Close() + + return execute(ctx, stmt) +} + +func execute(ctx context.Context, stmt *sql.Stmt) error { + if _, err := stmt.ExecContext(ctx); err != nil { + return err + } + return nil +} + +func parseQuery(m map[string]string, tpl string) string { + + if m == nil || len(m) <= 0 { + return tpl + } + + for k, v := range m { + tpl = strings.Replace(tpl, fmt.Sprintf("{{%s}}", k), v, -1) + } + return tpl +} diff --git a/helper/parseutil/parseutil.go b/helper/parseutil/parseutil.go index 464b50899c..ae8c58ba78 100644 --- a/helper/parseutil/parseutil.go +++ b/helper/parseutil/parseutil.go @@ -3,10 +3,13 @@ package parseutil import ( "encoding/json" "errors" + "fmt" "strconv" "strings" "time" + "github.com/hashicorp/errwrap" + sockaddr "github.com/hashicorp/go-sockaddr" "github.com/hashicorp/vault/helper/strutil" "github.com/mitchellh/mapstructure" ) @@ -118,3 +121,43 @@ func ParseCommaStringSlice(in interface{}) ([]string, error) { } return strutil.TrimStrings(result), nil } + +func ParseAddrs(addrs interface{}) ([]*sockaddr.SockAddrMarshaler, error) { + out := make([]*sockaddr.SockAddrMarshaler, 0) + stringAddrs := make([]string, 0) + + switch addrs.(type) { + case string: + stringAddrs = strutil.ParseArbitraryStringSlice(addrs.(string), ",") + if len(stringAddrs) == 0 { + return nil, fmt.Errorf("unable to parse addresses from %v", addrs) + } + + case []string: + stringAddrs = addrs.([]string) + + case []interface{}: + for _, v := range addrs.([]interface{}) { + stringAddr, ok := v.(string) + if !ok { + return nil, fmt.Errorf("error parsing %v as string", v) + } + stringAddrs = append(stringAddrs, stringAddr) + } + + default: + return nil, fmt.Errorf("unknown address input type %T", addrs) + } + + for _, addr := range stringAddrs { + sa, err := sockaddr.NewSockAddr(addr) + if err != nil { + return nil, errwrap.Wrapf(fmt.Sprintf("error parsing address %q: {{err}}", addr), err) + } + out = append(out, &sockaddr.SockAddrMarshaler{ + SockAddr: sa, + }) + } + + return out, nil +} diff --git a/helper/proxyutil/proxyutil.go b/helper/proxyutil/proxyutil.go index 06371b29e5..875e74831c 100644 --- a/helper/proxyutil/proxyutil.go +++ b/helper/proxyutil/proxyutil.go @@ -8,7 +8,7 @@ import ( proxyproto "github.com/armon/go-proxyproto" "github.com/hashicorp/errwrap" sockaddr "github.com/hashicorp/go-sockaddr" - "github.com/hashicorp/vault/helper/strutil" + "github.com/hashicorp/vault/helper/parseutil" ) // ProxyProtoConfig contains configuration for the PROXY protocol @@ -19,42 +19,12 @@ type ProxyProtoConfig struct { } func (p *ProxyProtoConfig) SetAuthorizedAddrs(addrs interface{}) error { - p.AuthorizedAddrs = make([]*sockaddr.SockAddrMarshaler, 0) - stringAddrs := make([]string, 0) - - switch addrs.(type) { - case string: - stringAddrs = strutil.ParseArbitraryStringSlice(addrs.(string), ",") - if len(stringAddrs) == 0 { - return fmt.Errorf("unable to parse addresses from %v", addrs) - } - - case []string: - stringAddrs = addrs.([]string) - - case []interface{}: - for _, v := range addrs.([]interface{}) { - stringAddr, ok := v.(string) - if !ok { - return fmt.Errorf("error parsing %v as string", v) - } - stringAddrs = append(stringAddrs, stringAddr) - } - - default: - return fmt.Errorf("unknown address input type %T", addrs) - } - - for _, addr := range stringAddrs { - sa, err := sockaddr.NewSockAddr(addr) - if err != nil { - return errwrap.Wrapf("error parsing authorized address: {{err}}", err) - } - p.AuthorizedAddrs = append(p.AuthorizedAddrs, &sockaddr.SockAddrMarshaler{ - SockAddr: sa, - }) + aa, err := parseutil.ParseAddrs(addrs) + if err != nil { + return err } + p.AuthorizedAddrs = aa return nil } diff --git a/http/forwarded_for_test.go b/http/forwarded_for_test.go new file mode 100644 index 0000000000..5d60391353 --- /dev/null +++ b/http/forwarded_for_test.go @@ -0,0 +1,249 @@ +package http + +import ( + "bytes" + "net/http" + "strings" + "testing" + + sockaddr "github.com/hashicorp/go-sockaddr" + "github.com/hashicorp/vault/vault" +) + +func TestHandler_XForwardedFor(t *testing.T) { + goodAddr, err := sockaddr.NewIPAddr("127.0.0.1") + if err != nil { + t.Fatal(err) + } + + badAddr, err := sockaddr.NewIPAddr("1.2.3.4") + if err != nil { + t.Fatal(err) + } + + // First: test reject not present + t.Run("reject_not_present", func(t *testing.T) { + t.Parallel() + testHandler := func(c *vault.Core) http.Handler { + origHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(r.RemoteAddr)) + }) + return WrapForwardedForHandler(origHandler, []*sockaddr.SockAddrMarshaler{ + &sockaddr.SockAddrMarshaler{ + SockAddr: goodAddr, + }, + }, true, false, 0) + } + + cluster := vault.NewTestCluster(t, nil, &vault.TestClusterOptions{ + HandlerFunc: testHandler, + }) + cluster.Start() + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + req := client.NewRequest("GET", "/") + _, err = client.RawRequest(req) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "missing x-forwarded-for") { + t.Fatalf("bad error message: %v", err) + } + req = client.NewRequest("GET", "/") + req.Headers = make(http.Header) + req.Headers.Set("x-forwarded-for", "1.2.3.4") + resp, err := client.RawRequest(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + buf := bytes.NewBuffer(nil) + buf.ReadFrom(resp.Body) + if !strings.HasPrefix(buf.String(), "1.2.3.4:") { + t.Fatalf("bad body: %s", buf.String()) + } + }) + + // Next: test allow unauth + t.Run("allow_unauth", func(t *testing.T) { + t.Parallel() + testHandler := func(c *vault.Core) http.Handler { + origHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(r.RemoteAddr)) + }) + return WrapForwardedForHandler(origHandler, []*sockaddr.SockAddrMarshaler{ + &sockaddr.SockAddrMarshaler{ + SockAddr: badAddr, + }, + }, true, false, 0) + } + + cluster := vault.NewTestCluster(t, nil, &vault.TestClusterOptions{ + HandlerFunc: testHandler, + }) + cluster.Start() + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + req := client.NewRequest("GET", "/") + req.Headers = make(http.Header) + req.Headers.Set("x-forwarded-for", "5.6.7.8") + resp, err := client.RawRequest(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + buf := bytes.NewBuffer(nil) + buf.ReadFrom(resp.Body) + if !strings.HasPrefix(buf.String(), "127.0.0.1:") { + t.Fatalf("bad body: %s", buf.String()) + } + }) + + // Next: test fail unauth + t.Run("fail_unauth", func(t *testing.T) { + t.Parallel() + testHandler := func(c *vault.Core) http.Handler { + origHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(r.RemoteAddr)) + }) + return WrapForwardedForHandler(origHandler, []*sockaddr.SockAddrMarshaler{ + &sockaddr.SockAddrMarshaler{ + SockAddr: badAddr, + }, + }, true, true, 0) + } + + cluster := vault.NewTestCluster(t, nil, &vault.TestClusterOptions{ + HandlerFunc: testHandler, + }) + cluster.Start() + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + req := client.NewRequest("GET", "/") + req.Headers = make(http.Header) + req.Headers.Set("x-forwarded-for", "5.6.7.8") + _, err = client.RawRequest(req) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "not authorized for x-forwarded-for") { + t.Fatalf("bad error message: %v", err) + } + }) + + // Next: test bad hops (too many) + t.Run("too_many_hops", func(t *testing.T) { + t.Parallel() + testHandler := func(c *vault.Core) http.Handler { + origHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(r.RemoteAddr)) + }) + return WrapForwardedForHandler(origHandler, []*sockaddr.SockAddrMarshaler{ + &sockaddr.SockAddrMarshaler{ + SockAddr: goodAddr, + }, + }, true, true, 4) + } + + cluster := vault.NewTestCluster(t, nil, &vault.TestClusterOptions{ + HandlerFunc: testHandler, + }) + cluster.Start() + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + req := client.NewRequest("GET", "/") + req.Headers = make(http.Header) + req.Headers.Set("x-forwarded-for", "2.3.4.5,3.4.5.6") + _, err = client.RawRequest(req) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "would skip before earliest") { + t.Fatalf("bad error message: %v", err) + } + }) + + // Next: test picking correct value + t.Run("correct_hop_skipping", func(t *testing.T) { + t.Parallel() + testHandler := func(c *vault.Core) http.Handler { + origHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(r.RemoteAddr)) + }) + return WrapForwardedForHandler(origHandler, []*sockaddr.SockAddrMarshaler{ + &sockaddr.SockAddrMarshaler{ + SockAddr: goodAddr, + }, + }, true, true, 1) + } + + cluster := vault.NewTestCluster(t, nil, &vault.TestClusterOptions{ + HandlerFunc: testHandler, + }) + cluster.Start() + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + req := client.NewRequest("GET", "/") + req.Headers = make(http.Header) + req.Headers.Set("x-forwarded-for", "2.3.4.5,3.4.5.6,4.5.6.7,5.6.7.8") + resp, err := client.RawRequest(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + buf := bytes.NewBuffer(nil) + buf.ReadFrom(resp.Body) + if !strings.HasPrefix(buf.String(), "4.5.6.7:") { + t.Fatalf("bad body: %s", buf.String()) + } + }) + + // Next: multi-header approach + t.Run("correct_hop_skipping_multi_header", func(t *testing.T) { + t.Parallel() + testHandler := func(c *vault.Core) http.Handler { + origHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(r.RemoteAddr)) + }) + return WrapForwardedForHandler(origHandler, []*sockaddr.SockAddrMarshaler{ + &sockaddr.SockAddrMarshaler{ + SockAddr: goodAddr, + }, + }, true, true, 1) + } + + cluster := vault.NewTestCluster(t, nil, &vault.TestClusterOptions{ + HandlerFunc: testHandler, + }) + cluster.Start() + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + req := client.NewRequest("GET", "/") + req.Headers = make(http.Header) + req.Headers.Add("x-forwarded-for", "2.3.4.5") + req.Headers.Add("x-forwarded-for", "3.4.5.6,4.5.6.7") + req.Headers.Add("x-forwarded-for", "5.6.7.8") + resp, err := client.RawRequest(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + buf := bytes.NewBuffer(nil) + buf.ReadFrom(resp.Body) + if !strings.HasPrefix(buf.String(), "4.5.6.7:") { + t.Fatalf("bad body: %s", buf.String()) + } + }) +} diff --git a/http/handler.go b/http/handler.go index a4e284dc36..72294b2bda 100644 --- a/http/handler.go +++ b/http/handler.go @@ -4,7 +4,9 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" + "net/textproto" "net/url" "os" "strings" @@ -13,6 +15,7 @@ import ( "github.com/elazarl/go-bindata-assetfs" "github.com/hashicorp/errwrap" cleanhttp "github.com/hashicorp/go-cleanhttp" + sockaddr "github.com/hashicorp/go-sockaddr" "github.com/hashicorp/vault/helper/consts" "github.com/hashicorp/vault/helper/jsonutil" "github.com/hashicorp/vault/helper/parseutil" @@ -124,6 +127,94 @@ func wrapGenericHandler(h http.Handler) http.Handler { }) } +func WrapForwardedForHandler(h http.Handler, authorizedAddrs []*sockaddr.SockAddrMarshaler, rejectNotPresent, rejectNonAuthz bool, hopSkips int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers, headersOK := r.Header[textproto.CanonicalMIMEHeaderKey("X-Forwarded-For")] + if !headersOK || len(headers) == 0 { + if !rejectNotPresent { + h.ServeHTTP(w, r) + return + } + respondError(w, http.StatusBadRequest, fmt.Errorf("missing x-forwarded-for header and configured to reject when not present")) + return + } + + host, port, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + // If not rejecting treat it like we just don't have a valid + // header because we can't do a comparison against an address we + // can't understand + if !rejectNotPresent { + h.ServeHTTP(w, r) + return + } + respondError(w, http.StatusBadRequest, errwrap.Wrapf("error parsing client hostport: {{err}}", err)) + return + } + + addr, err := sockaddr.NewIPAddr(host) + if err != nil { + // We treat this the same as the case above + if !rejectNotPresent { + h.ServeHTTP(w, r) + return + } + respondError(w, http.StatusBadRequest, errwrap.Wrapf("error parsing client address: {{err}}", err)) + return + } + + var found bool + for _, authz := range authorizedAddrs { + if authz.Contains(addr) { + found = true + break + } + } + if !found { + // If we didn't find it and aren't configured to reject, simply + // don't trust it + if !rejectNonAuthz { + h.ServeHTTP(w, r) + return + } + respondError(w, http.StatusBadRequest, fmt.Errorf("client address not authorized for x-forwarded-for and configured to reject connection")) + return + } + + // At this point we have at least one value and it's authorized + + // Split comma separated ones, which are common. This brings it in line + // to the multiple-header case. + var acc []string + for _, header := range headers { + vals := strings.Split(header, ",") + for _, v := range vals { + acc = append(acc, strings.TrimSpace(v)) + } + } + + indexToUse := len(acc) - 1 - hopSkips + if indexToUse < 0 { + // This is likely an error in either configuration or other + // infrastructure. We could either deny the request, or we + // could simply not trust the value. Denying the request is + // "safer" since if this logic is configured at all there may + // be an assumption it can always be trusted. Given that we can + // deny accepting the request at all if it's not from an + // authorized address, if we're at this point the address is + // authorized (or we've turned off explicit rejection) and we + // should assume that what comes in should be properly + // formatted. + respondError(w, http.StatusBadRequest, fmt.Errorf("malformed x-forwarded-for configuration or request, hops to skip (%d) would skip before earliest chain link (chain length %d)", hopSkips, len(headers))) + return + } + + r.RemoteAddr = net.JoinHostPort(acc[indexToUse], port) + h.ServeHTTP(w, r) + return + }) +} + // A lookup on a token that is about to expire returns nil, which means by the // time we can validate a wrapping token lookup will return nil since it will // be revoked after the call. So we have to do the validation here. diff --git a/plugins/database/hana/hana.go b/plugins/database/hana/hana.go index 1fdafe77ad..62e739a669 100644 --- a/plugins/database/hana/hana.go +++ b/plugins/database/hana/hana.go @@ -11,6 +11,7 @@ import ( _ "github.com/SAP/go-hdb/driver" "github.com/hashicorp/vault/api" "github.com/hashicorp/vault/builtin/logical/database/dbplugin" + "github.com/hashicorp/vault/helper/dbtxn" "github.com/hashicorp/vault/helper/strutil" "github.com/hashicorp/vault/plugins" "github.com/hashicorp/vault/plugins/helper/database/connutil" @@ -143,16 +144,12 @@ func (h *HANA) CreateUser(ctx context.Context, statements dbplugin.Statements, u continue } - stmt, err := tx.PrepareContext(ctx, dbutil.QueryHelper(query, map[string]string{ + m := map[string]string{ "name": username, "password": password, "expiration": expirationStr, - })) - if err != nil { - return "", "", err } - defer stmt.Close() - if _, err := stmt.ExecContext(ctx); err != nil { + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { return "", "", err } } @@ -238,14 +235,10 @@ func (h *HANA) RevokeUser(ctx context.Context, statements dbplugin.Statements, u continue } - stmt, err := tx.PrepareContext(ctx, dbutil.QueryHelper(query, map[string]string{ + m := map[string]string{ "name": username, - })) - if err != nil { - return err } - defer stmt.Close() - if _, err := stmt.ExecContext(ctx); err != nil { + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { return err } } diff --git a/plugins/database/mssql/mssql.go b/plugins/database/mssql/mssql.go index 84f7e1462a..9b0a78c0ae 100644 --- a/plugins/database/mssql/mssql.go +++ b/plugins/database/mssql/mssql.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/errwrap" "github.com/hashicorp/vault/api" "github.com/hashicorp/vault/builtin/logical/database/dbplugin" + "github.com/hashicorp/vault/helper/dbtxn" "github.com/hashicorp/vault/helper/strutil" "github.com/hashicorp/vault/plugins" "github.com/hashicorp/vault/plugins/helper/database/connutil" @@ -129,16 +130,13 @@ func (m *MSSQL) CreateUser(ctx context.Context, statements dbplugin.Statements, continue } - stmt, err := tx.PrepareContext(ctx, dbutil.QueryHelper(query, map[string]string{ + m := map[string]string{ "name": username, "password": password, "expiration": expirationStr, - })) - if err != nil { - return "", "", err } - defer stmt.Close() - if _, err := stmt.ExecContext(ctx); err != nil { + + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { return "", "", err } } @@ -189,14 +187,10 @@ func (m *MSSQL) RevokeUser(ctx context.Context, statements dbplugin.Statements, continue } - stmt, err := tx.PrepareContext(ctx, dbutil.QueryHelper(query, map[string]string{ + m := map[string]string{ "name": username, - })) - if err != nil { - return err } - defer stmt.Close() - if _, err := stmt.ExecContext(ctx); err != nil { + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { return err } } @@ -285,14 +279,7 @@ func (m *MSSQL) revokeUserDefault(ctx context.Context, username string) error { // many permissions as possible right now var lastStmtError error for _, query := range revokeStmts { - stmt, err := db.PrepareContext(ctx, query) - if err != nil { - lastStmtError = err - continue - } - defer stmt.Close() - _, err = stmt.ExecContext(ctx) - if err != nil { + if err := dbtxn.ExecuteDBQuery(ctx, db, nil, query); err != nil { lastStmtError = err } } @@ -355,16 +342,12 @@ func (m *MSSQL) RotateRootCredentials(ctx context.Context, statements []string) if len(query) == 0 { continue } - stmt, err := tx.PrepareContext(ctx, dbutil.QueryHelper(query, map[string]string{ + + m := map[string]string{ "username": m.Username, "password": password, - })) - if err != nil { - return nil, err } - - defer stmt.Close() - if _, err := stmt.ExecContext(ctx); err != nil { + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { return nil, err } } diff --git a/plugins/database/mysql/mysql.go b/plugins/database/mysql/mysql.go index 00fe475045..a36f1a8686 100644 --- a/plugins/database/mysql/mysql.go +++ b/plugins/database/mysql/mysql.go @@ -10,6 +10,7 @@ import ( stdmysql "github.com/go-sql-driver/mysql" "github.com/hashicorp/vault/api" "github.com/hashicorp/vault/builtin/logical/database/dbplugin" + "github.com/hashicorp/vault/helper/dbtxn" "github.com/hashicorp/vault/helper/strutil" "github.com/hashicorp/vault/plugins" "github.com/hashicorp/vault/plugins/helper/database/connutil" @@ -182,10 +183,11 @@ func (m *MySQL) CreateUser(ctx context.Context, statements dbplugin.Statements, return "", "", err } - defer stmt.Close() if _, err := stmt.ExecContext(ctx); err != nil { + stmt.Close() return "", "", err } + stmt.Close() } } @@ -291,16 +293,12 @@ func (m *MySQL) RotateRootCredentials(ctx context.Context, statements []string) if len(query) == 0 { continue } - stmt, err := tx.PrepareContext(ctx, dbutil.QueryHelper(query, map[string]string{ + + m := map[string]string{ "username": m.Username, "password": password, - })) - if err != nil { - return nil, err } - - defer stmt.Close() - if _, err := stmt.ExecContext(ctx); err != nil { + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { return nil, err } } diff --git a/plugins/database/postgresql/postgresql.go b/plugins/database/postgresql/postgresql.go index c56f9ed02d..36dd0036a9 100644 --- a/plugins/database/postgresql/postgresql.go +++ b/plugins/database/postgresql/postgresql.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/errwrap" "github.com/hashicorp/vault/api" "github.com/hashicorp/vault/builtin/logical/database/dbplugin" + "github.com/hashicorp/vault/helper/dbtxn" "github.com/hashicorp/vault/helper/strutil" "github.com/hashicorp/vault/plugins" "github.com/hashicorp/vault/plugins/helper/database/connutil" @@ -139,16 +140,12 @@ func (p *PostgreSQL) CreateUser(ctx context.Context, statements dbplugin.Stateme continue } - stmt, err := tx.PrepareContext(ctx, dbutil.QueryHelper(query, map[string]string{ + m := map[string]string{ "name": username, "password": password, "expiration": expirationStr, - })) - if err != nil { - return "", "", err } - defer stmt.Close() - if _, err := stmt.ExecContext(ctx); err != nil { + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { return "", "", err } } @@ -157,7 +154,6 @@ func (p *PostgreSQL) CreateUser(ctx context.Context, statements dbplugin.Stateme // Commit the transaction if err := tx.Commit(); err != nil { return "", "", err - } return username, password, nil @@ -198,16 +194,12 @@ func (p *PostgreSQL) RenewUser(ctx context.Context, statements dbplugin.Statemen if len(query) == 0 { continue } - stmt, err := tx.PrepareContext(ctx, dbutil.QueryHelper(query, map[string]string{ + + m := map[string]string{ "name": username, "expiration": expirationStr, - })) - if err != nil { - return err } - - defer stmt.Close() - if _, err := stmt.ExecContext(ctx); err != nil { + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { return err } } @@ -251,15 +243,10 @@ func (p *PostgreSQL) customRevokeUser(ctx context.Context, username string, revo continue } - stmt, err := tx.PrepareContext(ctx, dbutil.QueryHelper(query, map[string]string{ + m := map[string]string{ "name": username, - })) - if err != nil { - return err } - defer stmt.Close() - - if _, err := stmt.ExecContext(ctx); err != nil { + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { return err } } @@ -352,14 +339,7 @@ func (p *PostgreSQL) defaultRevokeUser(ctx context.Context, username string) err // many permissions as possible right now var lastStmtError error for _, query := range revocationStmts { - stmt, err := db.PrepareContext(ctx, query) - if err != nil { - lastStmtError = err - continue - } - defer stmt.Close() - _, err = stmt.ExecContext(ctx) - if err != nil { + if err := dbtxn.ExecuteDBQuery(ctx, db, nil, query); err != nil { lastStmtError = err } } @@ -423,16 +403,11 @@ func (p *PostgreSQL) RotateRootCredentials(ctx context.Context, statements []str if len(query) == 0 { continue } - stmt, err := tx.PrepareContext(ctx, dbutil.QueryHelper(query, map[string]string{ + m := map[string]string{ "username": p.Username, "password": password, - })) - if err != nil { - return nil, err } - - defer stmt.Close() - if _, err := stmt.ExecContext(ctx); err != nil { + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { return nil, err } } diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js index a2ebe58449..2790508a4e 100644 --- a/ui/app/components/auth-form.js +++ b/ui/app/components/auth-form.js @@ -3,7 +3,21 @@ import { supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; const BACKENDS = supportedAuthBackends(); const { computed, inject } = Ember; -export default Ember.Component.extend({ +const attributesForSelectedAuthBackend = { + token: ['token'], + userpass: ['username', 'password'], + ldap: ['username', 'password'], + github: ['username', 'password'], + okta: ['username', 'password'], +}; + +const DEFAULTS = { + token: null, + username: null, + password: null, +}; + +export default Ember.Component.extend(DEFAULTS, { classNames: ['auth-form'], routing: inject.service('-routing'), auth: inject.service(), @@ -14,6 +28,21 @@ export default Ember.Component.extend({ this.$('li.is-active').get(0).scrollIntoView(); }, + didReceiveAttrs() { + this._super(...arguments); + let newMethod = this.get('selectedAuthType'); + let oldMethod = this.get('oldSelectedAuthType'); + + if (oldMethod && oldMethod !== newMethod) { + this.resetDefaults(); + } + this.set('oldSelectedAuthType', newMethod); + }, + + resetDefaults() { + this.setProperties(DEFAULTS); + }, + cluster: null, redirectTo: null, @@ -22,9 +51,9 @@ export default Ember.Component.extend({ return BACKENDS.findBy('type', this.get('selectedAuthType')); }), - providerComponentName: Ember.computed('selectedAuthBackend.type', function() { - const type = Ember.String.dasherize(this.get('selectedAuthBackend.type')); - return `auth-form/${type}`; + providerPartialName: Ember.computed('selectedAuthType', function() { + const type = Ember.String.dasherize(this.get('selectedAuthType')); + return `partials/auth-form/${type}`; }), hasCSPError: computed.alias('csp.connectionViolations.firstObject'), @@ -45,15 +74,18 @@ export default Ember.Component.extend({ }, actions: { - doSubmit(data) { + doSubmit() { + let data = {}; this.setProperties({ loading: true, error: null, }); - const targetRoute = this.get('redirectTo') || 'vault.cluster'; - //const {password, token, username} = data; - const backend = this.get('selectedAuthBackend.type'); - const path = this.get('customPath'); + let targetRoute = this.get('redirectTo') || 'vault.cluster'; + let backend = this.get('selectedAuthBackend.type'); + let path = this.get('customPath'); + let attributes = attributesForSelectedAuthBackend[backend]; + + data = Ember.assign(data, this.getProperties(...attributes)); if (this.get('useCustomPath') && path) { data.path = path; } diff --git a/ui/app/components/download-button.js b/ui/app/components/download-button.js index 72fee1106e..2e2847c887 100644 --- a/ui/app/components/download-button.js +++ b/ui/app/components/download-button.js @@ -12,7 +12,7 @@ export default Ember.Component.extend({ return `${this.get('filename')}-${new Date().toISOString()}.${this.get('extension')}`; }), - fileLike: computed('data', 'mime', 'strigify', 'download', function() { + fileLike: computed('data', 'mime', 'stringify', 'download', function() { let file; let data = this.get('data'); let filename = this.get('download'); diff --git a/ui/app/styles/components/shamir-progress.scss b/ui/app/styles/components/shamir-progress.scss index 4bb418328a..1169b45a4d 100644 --- a/ui/app/styles/components/shamir-progress.scss +++ b/ui/app/styles/components/shamir-progress.scss @@ -1,11 +1,12 @@ .shamir-progress { .shamir-progress-progress { display: inline-block; + margin-top: $size-10; margin-right: $size-8; } .progress { box-shadow: 0 0 0 4px $progress-bar-background-color; - display: inline; - width: 150px; + margin-top: $size-10; + min-width: 90px; } } diff --git a/ui/app/templates/components/auth-form.hbs b/ui/app/templates/components/auth-form.hbs index 73b35345a4..2d94e536ad 100644 --- a/ui/app/templates/components/auth-form.hbs +++ b/ui/app/templates/components/auth-form.hbs @@ -1,7 +1,7 @@ -
- {{#if (and cluster.standby hasCSPError)}} - {{message-error errorMessage=cspErrorText data-test-auth-error=true}} - {{else}} - {{message-error errorMessage=error data-test-auth-error=true}} - {{/if}} - {{component providerComponentName onSubmit=(action 'doSubmit') }} -
- {{#unless (eq selectedAuthBackend.type "token")}} - {{toggle-button toggleTarget=this toggleAttr="useCustomPath"}} -
- {{#if useCustomPath}} - -
- -
-

- If this backend was mounted using a non-default path, enter it here. -

- {{/if}} -
- {{/unless}} +
+
+ {{#if (and cluster.standby hasCSPError)}} + {{message-error errorMessage=cspErrorText data-test-auth-error=true}} + {{else}} + {{message-error errorMessage=error data-test-auth-error=true}} + {{/if}} + {{partial providerPartialName}} +
+ {{#unless (eq selectedAuthBackend.type "token")}} + {{toggle-button toggleTarget=this toggleAttr="useCustomPath"}} +
+ {{#if useCustomPath}} + +
+ +
+

+ If this backend was mounted using a non-default path, enter it here. +

+ {{/if}} +
+ {{/unless}} +
-
-
- -
+
+ +
+ diff --git a/ui/app/templates/components/auth-form/git-hub.hbs b/ui/app/templates/components/auth-form/git-hub.hbs deleted file mode 100644 index a87e98afc0..0000000000 --- a/ui/app/templates/components/auth-form/git-hub.hbs +++ /dev/null @@ -1,17 +0,0 @@ -
-
- -
- {{input - type="password" - value=token - name="token" - id="token" - class="input" - }} -
-
-
diff --git a/ui/app/templates/components/auth-form/token.hbs b/ui/app/templates/components/auth-form/token.hbs deleted file mode 100644 index 7f89628f95..0000000000 --- a/ui/app/templates/components/auth-form/token.hbs +++ /dev/null @@ -1,17 +0,0 @@ -
-
- -
- {{input - type="password" - value=token - name="token" - class="input" - data-test-token=true - }} -
-
-
diff --git a/ui/app/templates/components/shamir-flow.hbs b/ui/app/templates/components/shamir-flow.hbs index 5c5d22403b..91b7f11964 100644 --- a/ui/app/templates/components/shamir-flow.hbs +++ b/ui/app/templates/components/shamir-flow.hbs @@ -114,9 +114,9 @@
{{else}} -
- {{message-error errors=errors}} -
+ +
+ {{message-error errors=errors}}
{{#if hasBlock}} {{yield}} @@ -132,29 +132,28 @@ {{input class="input"type="password" name="key" value=key data-test-shamir-input=true}}
-
-
-
-
-
- -
-
- {{#if (or started hasProgress)}} - {{shamir-progress - threshold=threshold - progress=progress - }} - {{/if}} +
+
+
+
+ +
+
+ {{#if (or started hasProgress)}} + {{shamir-progress + threshold=threshold + progress=progress + }} + {{/if}} +
-
+ {{/if}} diff --git a/ui/app/templates/components/shamir-progress.hbs b/ui/app/templates/components/shamir-progress.hbs index 63f9f14be8..053c283cf9 100644 --- a/ui/app/templates/components/shamir-progress.hbs +++ b/ui/app/templates/components/shamir-progress.hbs @@ -1,8 +1,10 @@
-
+
{{progress}} / {{threshold}} keys provided +
+
diff --git a/ui/app/templates/partials/auth-form/git-hub.hbs b/ui/app/templates/partials/auth-form/git-hub.hbs new file mode 100644 index 0000000000..90333e5c85 --- /dev/null +++ b/ui/app/templates/partials/auth-form/git-hub.hbs @@ -0,0 +1,13 @@ +
+ +
+ {{input + type="password" + value=token + name="token" + id="token" + class="input" + data-test-token=true + }} +
+
diff --git a/ui/app/templates/components/auth-form/ldap.hbs b/ui/app/templates/partials/auth-form/ldap.hbs similarity index 100% rename from ui/app/templates/components/auth-form/ldap.hbs rename to ui/app/templates/partials/auth-form/ldap.hbs diff --git a/ui/app/templates/components/auth-form/okta.hbs b/ui/app/templates/partials/auth-form/okta.hbs similarity index 100% rename from ui/app/templates/components/auth-form/okta.hbs rename to ui/app/templates/partials/auth-form/okta.hbs diff --git a/ui/app/templates/partials/auth-form/token.hbs b/ui/app/templates/partials/auth-form/token.hbs new file mode 100644 index 0000000000..9428b38ed2 --- /dev/null +++ b/ui/app/templates/partials/auth-form/token.hbs @@ -0,0 +1,12 @@ +
+ +
+ {{input + type="password" + value=token + name="token" + class="input" + data-test-token=true + }} +
+
diff --git a/ui/app/templates/components/auth-form/userpass.hbs b/ui/app/templates/partials/auth-form/userpass.hbs similarity index 100% rename from ui/app/templates/components/auth-form/userpass.hbs rename to ui/app/templates/partials/auth-form/userpass.hbs diff --git a/ui/app/templates/partials/userpass-form.hbs b/ui/app/templates/partials/userpass-form.hbs index ec1945a298..e4c2d26360 100644 --- a/ui/app/templates/partials/userpass-form.hbs +++ b/ui/app/templates/partials/userpass-form.hbs @@ -1,28 +1,25 @@ -
-
- -
- {{input - value=username - name="username" - id="username" - class="input" - }} -
+
+ +
+ {{input + value=username + name="username" + id="username" + class="input" + data-test-username=true + }}
-
- -
- {{input - value=password - name="password" - id="password" - type="password" - class="input" - }} -
+
+
+ +
+ {{input + value=password + name="password" + id="password" + type="password" + class="input" + data-test-password=true + }}
- +
diff --git a/ui/app/templates/vault/cluster/init.hbs b/ui/app/templates/vault/cluster/init.hbs index 1eb3478244..db068bfa22 100644 --- a/ui/app/templates/vault/cluster/init.hbs +++ b/ui/app/templates/vault/cluster/init.hbs @@ -71,19 +71,19 @@
{{else}} -
-
+ +

@@ -156,18 +156,17 @@ {{pgp-list listLength=1 onDataUpdate=(action 'setRootKey')}}

{{/if}} - -
-
- -
+
+
+ +
+ {{/if}} {{/s.content}} {{/splash-page}} diff --git a/ui/package.json b/ui/package.json index fe048e1aad..0c4b5acda1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -57,7 +57,7 @@ "ember-cli-inject-live-reload": "^1.4.1", "ember-cli-mirage": "^0.4.1", "ember-cli-moment-shim": "2.2.1", - "ember-cli-page-object": "^1.13.0", + "ember-cli-page-object": "1.14", "ember-cli-pretender": "0.7.0", "ember-cli-qunit": "^4.0.0", "ember-cli-sass": "6.0.0", diff --git a/ui/tests/acceptance/auth-test.js b/ui/tests/acceptance/auth-test.js index 361d70e779..0f024de892 100644 --- a/ui/tests/acceptance/auth-test.js +++ b/ui/tests/acceptance/auth-test.js @@ -1,9 +1,13 @@ import { test } from 'qunit'; import moduleForAcceptance from 'vault/tests/helpers/module-for-acceptance'; import { supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; +import authForm from '../pages/components/auth-form'; +import { create } from 'ember-cli-page-object'; + +const component = create(authForm); moduleForAcceptance('Acceptance | auth', { - afterEach() { + beforeEach() { return authLogout(); }, }); @@ -25,3 +29,15 @@ test('auth query params', function(assert) { }); }); }); + +test('it clears token when changing selected auth method', function(assert) { + visit('/vault/auth'); + andThen(function() { + assert.equal(currentURL(), '/vault/auth'); + }); + component.token('token').tabs.filterBy('name', 'GitHub')[0].link(); + component.tabs.filterBy('name', 'Token')[0].link(); + andThen(function() { + assert.equal(component.tokenValue, '', 'it clears the token value when toggling methods'); + }); +}); diff --git a/ui/tests/pages/components/auth-form.js b/ui/tests/pages/components/auth-form.js index 921b300efe..7f930f505a 100644 --- a/ui/tests/pages/components/auth-form.js +++ b/ui/tests/pages/components/auth-form.js @@ -1,6 +1,14 @@ -import { clickable, text } from 'ember-cli-page-object'; +import { collection, clickable, fillable, text, value } from 'ember-cli-page-object'; export default { + tabs: collection('[data-test-auth-method]', { + name: text(), + link: clickable('[data-test-auth-method-link]'), + }), + username: fillable('[data-test-username]'), + token: fillable('[data-test-token]'), + tokenValue: value('[data-test-token]'), + password: fillable('[data-test-password]'), errorText: text('[data-test-auth-error]'), login: clickable('[data-test-auth-submit]'), }; diff --git a/ui/yarn.lock b/ui/yarn.lock index 88bf65570d..0648225e4d 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -2817,15 +2817,14 @@ ember-cli-normalize-entity-name@^1.0.0: dependencies: silent-error "^1.0.0" -ember-cli-page-object@^1.13.0: - version "1.13.0" - resolved "https://registry.yarnpkg.com/ember-cli-page-object/-/ember-cli-page-object-1.13.0.tgz#9ac9342d9f90a363c429fbb14f3ad5c0be11827a" +ember-cli-page-object@1.14: + version "1.14.1" + resolved "https://registry.yarnpkg.com/ember-cli-page-object/-/ember-cli-page-object-1.14.1.tgz#2e3599c204c56440c6c8154fc686c603816f877a" dependencies: ceibo "~2.0.0" ember-cli-babel "^6.6.0" ember-cli-node-assets "^0.2.2" ember-native-dom-helpers "^0.5.3" - ember-test-helpers "^0.6.3" jquery "^3.2.1" rsvp "^4.7.0" diff --git a/website/source/assets/images/vault-versioned-kv-1.png b/website/source/assets/images/vault-versioned-kv-1.png new file mode 100644 index 0000000000..ff9a11d6f3 Binary files /dev/null and b/website/source/assets/images/vault-versioned-kv-1.png differ diff --git a/website/source/assets/images/vault-versioned-kv-2.png b/website/source/assets/images/vault-versioned-kv-2.png new file mode 100644 index 0000000000..2f7948cb69 Binary files /dev/null and b/website/source/assets/images/vault-versioned-kv-2.png differ diff --git a/website/source/assets/images/vault-versioned-kv-3.png b/website/source/assets/images/vault-versioned-kv-3.png new file mode 100644 index 0000000000..00bf85c2a2 Binary files /dev/null and b/website/source/assets/images/vault-versioned-kv-3.png differ diff --git a/website/source/assets/images/vault-versioned-kv-4.png b/website/source/assets/images/vault-versioned-kv-4.png new file mode 100644 index 0000000000..b7293f5805 Binary files /dev/null and b/website/source/assets/images/vault-versioned-kv-4.png differ diff --git a/website/source/assets/images/vault-versioned-kv-5.png b/website/source/assets/images/vault-versioned-kv-5.png new file mode 100644 index 0000000000..5e6b5b2419 Binary files /dev/null and b/website/source/assets/images/vault-versioned-kv-5.png differ diff --git a/website/source/assets/images/vault-versioned-kv-6.png b/website/source/assets/images/vault-versioned-kv-6.png new file mode 100644 index 0000000000..769de7cbe8 Binary files /dev/null and b/website/source/assets/images/vault-versioned-kv-6.png differ diff --git a/website/source/docs/configuration/listener/tcp.html.md b/website/source/docs/configuration/listener/tcp.html.md index 45d70a982e..ff539fddc0 100644 --- a/website/source/docs/configuration/listener/tcp.html.md +++ b/website/source/docs/configuration/listener/tcp.html.md @@ -85,6 +85,25 @@ listener "tcp" { authentication for this listener. The default behavior (when this is false) is for Vault to request client certificates when available. +- `x_forwarded_for_authorized_addrs` `(string: )` – + Specifies the list of source IP addresses for which an X-Forwarded-For header + will be trusted. Comma-separated list or JSON array. This turns on + X-Forwarded-For support. + +- `x_forwarded_for_hop_skips` `(string: "0")` – The number of addresses that will be + skipped from the *rear* of the set of hops. For instance, for a header value + of `1.2.3.4, 2.3.4.5, 3.4.5.6`, if this value is set to `"1"`, the address that + will be used as the originating client IP is `2.3.4.5`. + +- `x_forwarded_for_reject_not_authorized` `(string: "true")` – If set false, + if there is an X-Forwarded-For header in a connection from an unauthorized + address, the header will be ignored and the client connection used as-is, + rather than the client connection rejected. + +- `x_forwarded_for_reject_not_present` `(string: "true")` – If set false, if + there is no X-Forwarded-For header or it is empty, the client address will be + used as-is, rather than the client connection rejected. + ## `tcp` Listener Examples ### Configuring TLS diff --git a/website/source/guides/operations/index.html.md b/website/source/guides/operations/index.html.md index 1a775f4d98..a7e2d41e62 100644 --- a/website/source/guides/operations/index.html.md +++ b/website/source/guides/operations/index.html.md @@ -33,8 +33,8 @@ walks you through the commands to activate the Vault servers in replication mode Please note that [Vault Replication](/docs/vault-enterprise/replication/index.html) is a Vault Enterprise feature. -- **[Enterprise Only]** [Vault Auto-unseal using AWS Key Management Service (KMS)](/guides/operations/autounseal-aws-kms.html) guide demonstrates an example -of how to use Terraform to provision an instance that utilizes an encryption key +- **[Enterprise Only]** [Vault Auto-unseal using AWS Key Management Service (KMS)](/guides/operations/autounseal-aws-kms.html) guide demonstrates an example of +how to use Terraform to provision an instance that utilizes an encryption key from AWS Key Management Service (KMS). - [Root Token Generation](/guides/operations/generate-root.html) guide diff --git a/website/source/guides/secret-mgmt/index.html.md b/website/source/guides/secret-mgmt/index.html.md index 3049dce35f..e68d8d7c90 100644 --- a/website/source/guides/secret-mgmt/index.html.md +++ b/website/source/guides/secret-mgmt/index.html.md @@ -21,6 +21,9 @@ secrets. - [Static Secrets](/guides/secret-mgmt/static-secrets.html) guide walks you through the steps to write secrets in Vault, and control who can access them. +- [Versioned KV Secret Engine](/guides/secret-mgmt/versioned-kv.html) guide +demonstrates the secret versioning capabilities provided by KV Secret Engine v2. + - [Secret as a Service: Dynamic Secrets](/guides/secret-mgmt/dynamic-secrets.html) guide demonstrates the Vault feature to generate database credentials on-demand so that each application or system can obtain its own credentials, diff --git a/website/source/guides/secret-mgmt/static-secrets.html.md b/website/source/guides/secret-mgmt/static-secrets.html.md index 161dea96da..1de4df2ce8 100644 --- a/website/source/guides/secret-mgmt/static-secrets.html.md +++ b/website/source/guides/secret-mgmt/static-secrets.html.md @@ -8,7 +8,7 @@ description: |- keys. --- -# Static Secrets +# Static Secrets - Key/Value Secret Engine Vault can be used to store any secret in a secure manner. The secrets may be SSL certificates and keys for your organization's domain, credentials to connect @@ -57,6 +57,8 @@ nonce prior to writing them to its persistent storage. The storage backend never sees the unencrypted value, so gaining access to the raw storage isn't enough to access your secrets. +~> **NOTE:** This guide demonstrates secret management using [v2 of the KV +secret engine](/docs/secrets/kv/kv-v2.html). ## Prerequisites @@ -116,17 +118,103 @@ at the same path. You will perform the following: -1. [Store the Google API key](#step1) -2. [Store the root certificate for MySQL](#step2) -3. [Generate a token for apps](#step3) -4. [Retrieve the secrets](#step4) +1. [Enable KV Secret Engine v2](#step1) +1. [Store the Google API key](#step2) +1. [Store the root certificate for MySQL](#step3) +1. [Generate a token for apps](#step4) +1. [Retrieve the secrets](#step5) ![Personas Introduction](/assets/images/vault-static-secrets.png) -Step 1 through 3 are performed by `devops` persona. Step 4 describes the +Step 1 through 4 are performed by `devops` persona. Step 5 describes the commands that `apps` persona runs to read secrets from Vault. -### Step 1: Store the Google API key +### Step 1: Enable KV Secret Engine v2 +(**Persona:** devops) + +Currently, when you start the Vault server in [**dev +mode**](/intro/getting-started/dev-server.html#starting-the-dev-server), it +automatically enables **v2** of the KV secret engine at **`secret/`**. If you +start the Vault server in non-dev mode, the default is v1. + +If you are running the server in **dev** mode, skip to [Step 2](#step2). +Otherwise, you must perform one of the following: + +- Option 1: Upgrade the v1 of KV secret engine to v2 +- Option 2: Enable the KV secret engine v2 at a different path + + +#### CLI command + +Option 1: To upgrade from **v1** to **v2**: + +```plaintext +$ vault kv enable-versioning secret/ +``` +
+Option 2: To enable the KV secret engine v2 at **`secret_v2/`**: + +```plaintext +$ vault secrets enable -path=secret_v2/ kv-v2 +``` + +Or + +```plaintext +$ vault secrets enable -path=secret_v2/ -version=2 kv +``` + + +#### API call using cURL + +Option 1: To upgrade from **v1** to **v2**: + +```plaintext +$ curl --header "X-Vault-Token: " \ + --request POST \ + --data @payload.json \ + /v1/sys/mounts/secret/tune +``` + +Where `` is your valid token, and `` is where your vault +server is running. The `payload.json` includes the version information. + + +**Example:** + +```plaintext +$ cat payload.json +{ + "options": { + "version": "2" + } +} + +$ curl --header "X-Vault-Token: ..." \ + --request POST \ + --data @payload.json \ + http://127.0.0.1:8200/v1/sys/mounts/secret/tune +``` + +
+Option 2: To enable the KV secret engine v2 at **`secret_v2/`**: + + +```plaintext +$ curl --header "X-Vault-Token: ..." \ + --request POST \ + --data '{"type":"kv-v2"}' \ + https://127.0.0.1:8200/v1/sys/mounts/secret_v2 +``` + + +
+ +~> **NOTE:** This guide assumes that you are working with KV secret engine +**v2** which is mounted at **`secret/`**. + + +### Step 2: Store the Google API key (**Persona:** devops) Everything after the **`secret/`** path is a key-value pair to write to the @@ -144,7 +232,7 @@ have an API key for New Relic owned by the DevOps team, the path would look like To create key/value secrets: -```shell +```plaintext $ vault kv put secret/ =VALUE> ``` @@ -153,7 +241,7 @@ decide on the naming convention that makes most sense. **Example:** -```shell +```plaintext $ vault kv put secret/eng/apikey/Google key=AAaaBBccDDeeOTXzSMT1234BB_Z8JzG7JkSVxI Success! Data written to: secret/eng/apikey/Google ``` @@ -163,23 +251,32 @@ this example. #### API call using cURL -Use `/secret/` endpoint to create secrets: +Use `/secret/data/` endpoint to create secrets: -```shell +```plaintext $ curl --header "X-Vault-Token: " \ --request POST \ - --data \ + --data @payload.json \ /v1/secret/data/ ``` -Where `` is your valid token, `` is the key-value pair(s) of your -secrets, and `secret/data/` is the path to your secrets. +Where `` is your valid token, and `secret/data/` is the path to +your secrets. The [`payload.json`](/api/secret/kv/kv-v2.html#parameters-2) +contains the parameters to invoke the endpoint. **Example:** -```shell +```plaintext +$ tee payload.json <Step 2: Store the root certificate for MySQL +### Step 3: Store the root certificate for MySQL (**Persona:** devops) For the purpose of this guide, generate a new self-sign certificate using [OpenSSL](https://www.openssl.org/source/). -```shell +```plaintext $ openssl req --request509 -sha256 -nodes -newkey rsa:2048 -keyout selfsigned.key -out cert.pem ``` @@ -235,7 +332,7 @@ store the root certificate for production MySQL, the path becomes **Example:** -```shell +```plaintext $ vault kv put secret/prod/cert/mysql cert=@cert.pem ``` @@ -250,7 +347,7 @@ To perform the same task using the Vault API, pass the token in the request head **Example:** -```shell +```plaintext $ curl --header "X-Vault-Token: ..." \ --request POST \ --data @cert.pem \ @@ -259,7 +356,7 @@ $ curl --header "X-Vault-Token: ..." \ > **NOTE:** Any value begins with "@" is loaded from a file. -### Step 3: Generate a token for apps +### Step 4: Generate a token for apps (**Persona:** devops) To read the secrets, `apps` persona needs "read" permit on those secret engine @@ -313,10 +410,11 @@ as an `app` persona. ```shell # Payload to pass in the API call -$ cat payload.json +$ tee payload.json <Step 4: Retrieve the secrets +### Step 5: Retrieve the secrets (**Persona:** apps) -Using the token from [Step 3](#step3), read the Google API key, and root certificate for +Using the token from [Step 4](#step4), read the Google API key, and root certificate for MySQL. @@ -374,7 +472,7 @@ MySQL. The command to read secret is: -```shell +```plaintext $ vault kv get secret/ ``` @@ -398,7 +496,7 @@ key AAaaBBccDDeeOTXzSMT1234BB_Z8JzG7JkSVxI To return the key value alone, pass `-field=key` as an argument. -```shell +```plaintext $ vault kv get -field=key secret/eng/apikey/Google AAaaBBccDDeeOTXzSMT1234BB_Z8JzG7JkSVxI ``` @@ -407,7 +505,7 @@ AAaaBBccDDeeOTXzSMT1234BB_Z8JzG7JkSVxI The command is basically the same: -```shell +```plaintext $ vault kv get -field=cert secret/prod/cert/mysql -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEA6E2Uq0XqreZISgVMUu9pnoMsq+OoK1PI54rsA9vtDE6wiRk0GWhf5vD4DGf1 @@ -418,7 +516,7 @@ MIIEowIBAAKCAQEA6E2Uq0XqreZISgVMUu9pnoMsq+OoK1PI54rsA9vtDE6wiRk0GWhf5vD4DGf1 Use `secret/` endpoint to retrieve secrets from key/value secret engine: -```shell +```plaintext $ curl --header "X-Vault-Token: " \ --request Get \ /v1/secret/data/ @@ -428,7 +526,7 @@ $ curl --header "X-Vault-Token: " \ Read the Google API key. -```shell +```plaintext $ curl --header "X-Vault-Token: 1c97b03a-6098-31cf-9d8b-b404e52dcb4a" \ --request GET \ http://127.0.0.1:8200/v1/secret/data/eng/apikey/Google | jq @@ -450,7 +548,7 @@ $ curl --header "X-Vault-Token: 1c97b03a-6098-31cf-9d8b-b404e52dcb4a" \ Retrieve the key value with `jq`: -```shell +```plaintext $ curl --header "X-Vault-Token: 1c97b03a-6098-31cf-9d8b-b404e52dcb4a" \ --request GET \ http://127.0.0.1:8200/v1/secret/data/eng/apikey/Google | jq ".data.key" @@ -458,7 +556,7 @@ $ curl --header "X-Vault-Token: 1c97b03a-6098-31cf-9d8b-b404e52dcb4a" \ #### Root certificate example: -```shell +```plaintext $ curl --header "X-Vault-Token: 1c97b03a-6098-31cf-9d8b-b404e52dcb4a" \ --request GET \ http://127.0.0.1:8200/v1/secret/data/prod/cert/mysql | jq ".data.cert" @@ -478,7 +576,7 @@ An easy technique is to use a dash "-" and then press Enter. This allows you to enter the secret in a new line. After entering the secret, press **`Ctrl+d`** to end the pipe and write the secret to the Vault. -```shell +```plaintext $ vault kv put secret/eng/apikey/Google key=- AAaaBBccDDeeOTXzSMT1234BB_Z8JzG7JkSVxI @@ -489,7 +587,7 @@ AAaaBBccDDeeOTXzSMT1234BB_Z8JzG7JkSVxI Using the Google API key example, you can create a file containing the key (apikey.txt): -```text +```plaintext { "key": "AAaaBBccDDeeOTXzSMT1234BB_Z8JzG7JkSVxI" } @@ -497,7 +595,7 @@ Using the Google API key example, you can create a file containing the key (apik The CLI command would look like: -```shell +```plaintext $ vault kv put secret/eng/apikey/Google @apikey.txt ``` @@ -510,7 +608,7 @@ in history. In bash: -```shell +```plaintext $ export HISTIGNORE="&:vault*" ``` @@ -522,23 +620,24 @@ $ export HISTIGNORE="&:vault*" The two examples introduced in this guide only had a single key-value pair. You can pass multiple values in the command. -```shell +```plaintext $ vault kv put secret/dev/config/mongodb url=foo.example.com:35533 db_name=users \ - username=admin password=pa$$w0rd + username=admin password=passw0rd ``` Or, read the secret from a file: -```shell -$ vault kv put secret/dev/config/mongodb @mongodb.txt - -$ cat mongodb.txt +```plaintext +$ tee mongodb.txt < **NOTE:** For the purpose of this guide, you can use **`root`** token to work +with Vault. However, it is recommended that root tokens are only used for +initial setup or in emergencies. As a best practice, use tokens with +appropriate set of policies based on your role in the organization. + +To perform all tasks demonstrated in this guide, your policy must include the +following permissions: + +```shell +# To view in Web UI +path "sys/mounts" { + capabilities = [ "read", "update" ] +} + +# Write and manage secrets in key-value secret engine +path "secret*" { + capabilities = [ "create", "read", "update", "delete", "list" ] +} + +# To enable secret engines +path "sys/mounts/*" { + capabilities = [ "create", "read", "update", "delete" ] +} +``` + +If you are not familiar with policies, complete the +[policies](/guides/identity/policies.html) guide. + + +## Steps + +This guide demonstrates the basic commands for working with KV secret engine v2. + +You will perform the following: + +1. [Check the KV secret engine version](#step1) +2. [Write secrets](#step2) +3. [Retrieve a specific version of secret](#step3) +4. [Specify the number of versions to keep](#step4) +5. [Delete versions of secret](#step5) +6. [Permanently delete data](#step6) + + +### Step 1: Check the KV secret engine version +(**Persona:** devops) + +Before beginning, verify that you are using the v2 of the KV secret engine. + +#### CLI command + +To check the KV secret engine version: + +```plaintext +$ vault secrets list -format=json +... +"secret/": { + "type": "kv", + "description": "key/value secret storage", + "accessor": "kv_f05b8b9c", + "config": { + "default_lease_ttl": 0, + "max_lease_ttl": 0, + "force_no_cache": false + }, + "options": { + "version": "2" + }, + ... +``` + +The indicated **`version`** should be **`2`**. If the version is **`1`**, +upgrade it to v2. + +```plaintext +$ vault kv enable-versioning secret/ +``` + +#### API call using cURL + +To check the KV secret engine version: + +```plaintext +$ curl --header "X-Vault-Token: " \ + /v1/sys/mounts +``` + +Where `` is your valid token, and `` is where your vault +server is running. + + +**Example:** + +```plaintext +$ curl --header "X-Vault-Token: ..." \ + http://127.0.0.1:8200/v1/sys/mounts | jq +... + "secret/": { + "accessor": "kv_f05b8b9c", + "config": { + "default_lease_ttl": 0, + "force_no_cache": false, + "max_lease_ttl": 0, + "plugin_name": "" + }, + "description": "key/value secret storage", + "local": false, + "options": { + "version": "2" + }, + "seal_wrap": false, + "type": "kv" + }, +... +``` + +The indicated **`version`** should be **`2`**. If the version is **`1`**, +upgrade it to v2. + +```plaintext +$ cat payload.json +{ + "options": { + "version": "2" + } +} + +$ curl --header "X-Vault-Token: ..." \ + --request POST \ + --data @payload.json \ + http://127.0.0.1:8200/v1/sys/mounts/secret/tune +``` + +#### Web UI + +Open a web browser and launch the Vault UI (e.g. `http://127.0.0.1:8200/ui`) and +then login. + +![Web UI](/assets/images/vault-versioned-kv-2.png) + +If `secret/` does not indicates **`v2`**, you can upgrade it from `v1` to `v2` +by executing the following CLI command: + +```plaintext +$ vault kv enable-versioning secret/ +``` + +Alternatively, you can enable KV secret engine v2 at a different path by +clicking **Enable new engine**. Select **KV** from the **Secret engine type** +drop-down list. Be sure that the **Version** is set to be **Version 2**. + +![Enabling kv-v2](/assets/images/vault-versioned-kv-3.png) + +Click **Enable Engine** to complete. + + +### Step 2: Write Secrets + +To understand how the versioning works, let's write some test data. + +#### CLI commands + +To write secrets, run `vault kv put` command instead of `vault write`: + +```plaintext +$ vault kv put secret/customer/acme name="ACME Inc." contact_email="jsmith@acme.com" +Key Value +--- ----- +created_time 2018-04-14T00:05:47.115378933Z +deletion_time n/a +destroyed false +version 1 +``` + +To update the existing secret, run the `vault kv put` command again: + +```plaintext +$ vault kv put secret/customer/acme name="ACME Inc." contact_email="john.smith@acme.com" +Key Value +--- ----- +created_time 2018-04-14T00:13:35.296018431Z +deletion_time n/a +destroyed false +version 2 +``` + +Now you have two versions of the `secret/customer/acme` data. Run `vault kv get` +to read the data. + +```plaintext +$ vault kv get secret/customer/acme +====== Metadata ====== +Key Value +--- ----- +created_time 2018-04-14T00:13:35.296018431Z +deletion_time n/a +destroyed false +version 2 + +======== Data ======== +Key Value +--- ----- +contact_email john.smith@acme.com +name ACME Inc. +``` + +#### API call using cURL + +Write some data at `secret/customer/acme`: + +```plaintext +$ tee payload.json <`**; therefore, to +write secrets at `secret/customer/acme`, the API endpoint becomes +`/secret/data/customer/acme`. + +Update the secret to create another version: + +```plaintext +$ tee payload.json <Step 3: Retrieve a Specific Version of Secret + +You may run into a situation where you need to view the secret before an update. + +#### CLI commands + +To retrieve the version 1 of the secret written at `secret/customer/acme`: + +```plaintext +$ vault kv get -version=1 secret/customer/acme +====== Metadata ====== +Key Value +--- ----- +created_time 2018-04-14T00:05:47.115378933Z +deletion_time n/a +destroyed false +version 1 + +======== Data ======== +Key Value +--- ----- +contact_email jsmith@acme.com +name ACME Inc. +``` + +To read the **metadata** of `secret/customer/acme`: + +```plaintext +$ vault kv metadata get secret/customer/acme +======= Metadata ======= +Key Value +--- ----- +created_time 2018-04-14T00:05:47.115378933Z +current_version 2 +max_versions 0 +oldest_version 0 +updated_time 2018-04-14T00:13:35.296018431Z + +====== Version 1 ====== +Key Value +--- ----- +created_time 2018-04-14T00:05:47.115378933Z +deletion_time n/a +destroyed false + +====== Version 2 ====== +Key Value +--- ----- +created_time 2018-04-14T00:13:35.296018431Z +deletion_time n/a +destroyed false +``` + + +#### API call using cURL + +To retrieve the version 1 of the secret written at `secret/customer/acme`: + +```plaintext +$ curl --header "X-Vault-Token: ..." \ + http://127.0.0.1:8200/v1/secret/data/customer/acme?version=1 | jq +{ + "request_id": "3bf5a2c1-d89b-9dd5-9bb5-0bc61a4a6d83", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "data": { + "contact_email": "jsmith@acme.com", + "name": "ACME Inc." + }, + "metadata": { + "created_time": "2018-04-14T00:05:47.115378933Z", + "deletion_time": "", + "destroyed": false, + "version": 1 + } + }, + "wrap_info": null, + "warnings": null, + "auth": null +} +``` + +To read the **metadata** of `secret/customer/acme`: + +```plaintext +$ curl --header "X-Vault-Token: ..." \ + http://127.0.0.1:8200/v1/secret/metadata/customer/acme | jq +{ + "request_id": "34708262-59cd-9a94-247f-3b1db0909050", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "created_time": "2018-04-14T00:05:47.115378933Z", + "current_version": 2, + "max_versions": 0, + "oldest_version": 0, + "updated_time": "2018-04-14T00:13:35.296018431Z", + "versions": { + "1": { + "created_time": "2018-04-14T00:05:47.115378933Z", + "deletion_time": "", + "destroyed": false + }, + "2": { + "created_time": "2018-04-14T00:13:35.296018431Z", + "deletion_time": "", + "destroyed": false + } + } + }, + "wrap_info": null, + "warnings": null, + "auth": null +} +``` + + +### Step 4: Specify the number of versions to keep + +By default, the `kv-v2` secret engine keeps up to 10 versions. Let's limit the +maximum number of versions to keep to be 4. + +#### CLI command + +To set the `secret/` to keep up to 4 versions: + +```shell +$ vault write secret/config max_versions=4 +Success! Data written to: secret/config + +# View the configuration settings +$ vault read secret/config +Key Value +--- ----- +cas_required false +max_versions 4 +``` + +Alternatively, to limit the number of versions only on the +**`secret/customer/acme`** path rather than the entire `secret/` engine: + +```plaintext +$ vault kv metadata put -max-versions=4 secret/customer/acme +``` + +Overwrite the data a few more times to see what happens to the data. + +```plaintext +$ vault kv metadata get secret/customer/acme +======= Metadata ======= +Key Value +--- ----- +created_time 2018-04-14T00:42:25.677078177Z +current_version 6 +max_versions 0 +oldest_version 3 +updated_time 2018-04-16T00:17:23.930473344Z + +====== Version 3 ====== +Key Value +--- ----- +created_time 2018-04-16T00:15:59.880368849Z +deletion_time n/a +destroyed false + +====== Version 4 ====== +Key Value +--- ----- +created_time 2018-04-16T00:16:18.941331243Z +deletion_time n/a +destroyed false + +====== Version 5 ====== +Key Value +--- ----- +created_time 2018-04-16T00:16:34.407951572Z +deletion_time n/a +destroyed false + +====== Version 6 ====== +Key Value +--- ----- +created_time 2018-04-16T00:17:23.930473344Z +deletion_time n/a +destroyed false +``` + +In this example, the current version is 6. Notice that version 1 and 2 do not +show up in the metadata. Because the kv secret engine is configured to keep only +4 versions, the oldest two versions are permanently deleted and you won't be +able to read them. + +```plaintext +$ vault kv get -version=1 secret/customer/acme +No value found at secret/data/customer/data +``` + +#### API call using cURL + +To set the `secret/` to keep up to 4 versions: + +```plaintext +$ tee payload.json<Step 5: Delete versions of secret + + +#### CLI command + +Let's delete versions 4 and 5: + +```shell +$ vault kv delete -versions="4,5" secret/customer/acme +Success! Data deleted (if it existed) at: secret/customer/acme + +# Check the metadata +$ vault kv metadata get secret/customer/acme +... +====== Version 4 ====== +Key Value +--- ----- +created_time 2018-04-16T00:12:25.404198622Z +deletion_time 2018-04-16T01:04:01.160426888Z +destroyed false + +====== Version 5 ====== +Key Value +--- ----- +created_time 2018-04-16T00:12:47.527981267Z +deletion_time 2018-04-16T01:04:01.160427742Z +destroyed false +... +``` + +The metadata on versions 4 and 5 reports its deletion timestamp +(`deletion_time`); however, the `destroyed` parameter is set to `false`. + +If version 5 was deleted by mistake and you wish to recover, invoke the `vault +kv undelete` command: + +```plaintext +$ vault kv undelete -versions=5 secret/customer/acme +Success! Data written to: secret/undelete/customer/acme +``` + + +#### API call using cURL + +Let's delete versions 4 and 5: + +```shell +$ curl --header "X-Vault-Token: ..." \ + --request POST \ + --data '{ "versions":[4,5] }' + http://127.0.0.1:8200/v1/secret/delete/customer/acme + +# Check the metadata +$ curl --header "X-Vault-Token: ..." \ + http://127.0.0.1:8200/v1/secret/metadata/customer/acme | jq +... +"4": { + "created_time": "2018-04-16T00:16:18.941331243Z", + "deletion_time": "2018-04-16T01:17:42.003111567Z", + "destroyed": false + }, + "5": { + "created_time": "2018-04-16T00:16:34.407951572Z", + "deletion_time": "2018-04-16T01:17:42.003111978Z", + "destroyed": false + }, +... +``` + +The metadata on versions 4 and 5 reports its deletion timestamp +(`deletion_time`); however, the `destroyed` parameter is set to `false`. + +If version 5 was deleted by mistake and you wish to recover, invoke the +`/secret/undelete` endpoint: + +```plaintext +$ curl --header "X-Vault-Token: ..." \ + --request POST \ + --data '{ "versions":[5] }' + http://127.0.0.1:8200/v1/secret/undelete/customer/acme +``` + + +### Step 6: Permanently delete data + +#### CLI command + +To permanently delete a version of secret: + +```shell +$ vault kv destroy -versions=4 secret/customer/acme +Success! Data written to: secret/destroy/customer/acme + +# Check the metadata +$ vault kv metadata get secret/customer/acme +... +====== Version 4 ====== +Key Value +--- ----- +created_time 2018-04-16T00:12:25.404198622Z +deletion_time 2018-04-16T01:04:01.160426888Z +destroyed true +... +``` + +The metadata indicates that Version 4 is destroyed. + +If you wish to destroy all the keys and versions at `secret/customer/acme`, +invoke the `vault kv metadata delete` command: + +```plaintext +$ vault kv metadata delete secret/customer/acme +Success! Data deleted (if it existed) at: secret/metadata/customer/acme +``` + + +#### API call using cURL + +To permanently delete a version of secret: + +```shell +$ curl --header "X-Vault-Token: ..." \ + --request POST \ + --data '{ "versions":[4] }' + http://127.0.0.1:8200/v1/secret/destroy/customer/acme + +# Check the metadata +$ curl --header "X-Vault-Token: ..." \ + http://127.0.0.1:8200/v1/secret/metadata/customer/acme | jq +... + "4": { + "created_time": "2018-04-16T00:16:18.941331243Z", + "deletion_time": "2018-04-16T01:17:42.003111567Z", + "destroyed": true + }, +... +``` + +The metadata indicates that Version 4 is destroyed. + +If you wish to destroy all the keys and versions at `secret/customer/acme`, +invoke the `secret/metadata` endpoint: + +```plaintext +$ curl --header "X-Vault-Token: ..." \ + --request DELETE + http://127.0.0.1:8200/v1/secret/metadata/customer/acme +``` + + +### Additional Discussion + +The v2 of KV secret engine supports a **_Check-And-Set_** operation to prevent +unintentional secret overwrite. When you pass the `cas` flag to Vault, it first +checks if the key already exists. + +By default, _Check-And-Set_ operation is not enabled on the KV secret engine; +therefore, write is always allowed (no checking is performed). + +```plaintext +$ vault read secret/config +Key Value +--- ----- +cas_required false +max_versions 0 +``` + +#### CLI command + +To enable the **_Check-And-Set_** operation: + +```shell +# Enable cas_requied on the secret engine mounted at secret/ +$ vault write secret/config cas-required=true + +# Enable cas_requied only on the secret/partner path +$ vault kv metadata put -cas-required=true secret/partner +``` + +Once check-and-set is enabled, every write operation requires `cas` value to be +passed. If you are sure that you want to overwrite the existing key-value, set +`cas` to match the current version. Set `cas` to `0` if you want to write the +secret _only if_ the key does not exists. + +**Example:** + +```shell +# To write if the key does not already exists +$ vault kv put -cas=0 secret/partner name="Example Co." partner_id="123456789" +Key Value +--- ----- +created_time 2018-04-16T22:58:15.798753323Z +deletion_time n/a +destroyed false +version 1 + +# To overwrite the secret, you must specify the current version with -cas flag +$ vault kv put -cas=1 secret/partner name="Example Co." partner_id="ABCDEFGHIJKLMN" +Key Value +--- ----- +created_time 2018-04-16T23:00:28.66552289Z +deletion_time n/a +destroyed false +version 2 +``` + +#### API call using cURL + +To enable the **_Check-And-Set_** operation: + +```shell +$ tee payload.json< + +~> If the **`cas`** value is missing in your write request, the +"`check-and-set parameter required for this call`" error will be returned. If +the `cas` does not match the current version number, you will receive the +"`check-and-set parameter did not match the current version`" message. + + + +## Next steps + +This guide introduced the CLI commands and API endpoints to read and write +static secrets in the key-value secret engine. Read [Secret as a Service: Dynamic Secrets](/guides/secret-mgmt/dynamic-secrets.html) guide to learn about the +usage of database secret engine. diff --git a/website/source/layouts/guides.erb b/website/source/layouts/guides.erb index da5f120b5f..8aed065902 100644 --- a/website/source/layouts/guides.erb +++ b/website/source/layouts/guides.erb @@ -61,6 +61,9 @@ > Static Secrets + > + Versioned KV Secret Engine + > Secret as a Service