diff --git a/.github/workflows/test-go.yml b/.github/workflows/test-go.yml index b565a4f20efc..9bb61654037a 100644 --- a/.github/workflows/test-go.yml +++ b/.github/workflows/test-go.yml @@ -49,6 +49,11 @@ on: required: false default: 20 type: number + go-test-timeout: + description: The timeout parameter for Go tests + required: false + default: 50m + type: string timeout-minutes: description: The maximum number of minutes that this workflow should run required: false @@ -443,7 +448,7 @@ jobs: -- \ $package_parallelism \ -tags "${{ inputs.go-tags }}" \ - -timeout=${{ env.TIMEOUT_IN_MINUTES }}m \ + -timeout=${{ inputs.go-test-timeout }} \ -parallel=${{ inputs.go-test-parallelism }} \ ${{ inputs.extra-flags }} \ - name: Prepare datadog-ci diff --git a/builtin/logical/pki/backend.go b/builtin/logical/pki/backend.go index 2b9044db4e16..615380a826fa 100644 --- a/builtin/logical/pki/backend.go +++ b/builtin/logical/pki/backend.go @@ -297,7 +297,9 @@ func Backend(conf *logical.BackendConfig) *backend { // Delay the first tidy until after we've started up, this will be reset within the initialize function now := time.Now() + b.tidyStatusLock.Lock() b.lastAutoTidy = now + b.tidyStatusLock.Unlock() // Keep track of when this mount was started up. b.mountStartup = now diff --git a/builtin/logical/pki/path_tidy.go b/builtin/logical/pki/path_tidy.go index a8971832c2b1..5e7a4b037681 100644 --- a/builtin/logical/pki/path_tidy.go +++ b/builtin/logical/pki/path_tidy.go @@ -1724,7 +1724,7 @@ func (b *backend) pathTidyStatusRead(_ context.Context, _ *logical.Request, _ *f "acme_account_safety_buffer": nil, "cert_metadata_deleted_count": nil, "cmpv2_nonce_deleted_count": nil, - "last_auto_tidy_finished": b.getLastAutoTidyTime(), + "last_auto_tidy_finished": b.getLastAutoTidyTimeWithoutLock(), // we acquired the tidyStatusLock above. }, } @@ -2126,6 +2126,12 @@ func (b *backend) updateLastAutoTidyTime(sc *storageContext, lastRunTime time.Ti func (b *backend) getLastAutoTidyTime() time.Time { b.tidyStatusLock.RLock() defer b.tidyStatusLock.RUnlock() + return b.getLastAutoTidyTimeWithoutLock() +} + +// getLastAutoTidyTimeWithoutLock should be used to read from b.lastAutoTidy with the +// b.tidyStatusLock being acquired, normally use getLastAutoTidyTime +func (b *backend) getLastAutoTidyTimeWithoutLock() time.Time { return b.lastAutoTidy } diff --git a/builtin/logical/pki/path_tidy_test.go b/builtin/logical/pki/path_tidy_test.go index 0b12e845b7d4..f32bc880a59e 100644 --- a/builtin/logical/pki/path_tidy_test.go +++ b/builtin/logical/pki/path_tidy_test.go @@ -301,41 +301,9 @@ func TestAutoTidy(t *testing.T) { // Wait for cert to expire and the safety buffer to elapse. time.Sleep(time.Until(leafCert.NotAfter) + 3*time.Second) - // Wait for auto-tidy to run afterwards. - var foundTidyRunning string - var foundTidyFinished bool - timeoutChan := time.After(120 * time.Second) - for { - if foundTidyRunning != "" && foundTidyFinished { - break - } - - select { - case <-timeoutChan: - t.Fatalf("expected auto-tidy to run (%v) and finish (%v) before 120 seconds elapsed", foundTidyRunning, foundTidyFinished) - default: - time.Sleep(250 * time.Millisecond) - - resp, err = client.Logical().Read("pki/tidy-status") - require.NoError(t, err) - require.NotNil(t, resp) - require.NotNil(t, resp.Data) - require.NotEmpty(t, resp.Data["state"]) - require.NotEmpty(t, resp.Data["time_started"]) - state := resp.Data["state"].(string) - started := resp.Data["time_started"].(string) - t.Logf("Resp: %v", resp.Data) - - // We want the _next_ tidy run after the cert expires. This - // means if we're currently finished when we hit this the - // first time, we want to wait for the next run. - if foundTidyRunning == "" { - foundTidyRunning = started - } else if foundTidyRunning != started && !foundTidyFinished && state == "Finished" { - foundTidyFinished = true - } - } - } + // We run this twice to make absolutely sure we didn't read a previous run of tidy + _, lastRun := waitForTidyToFinish(t, client, "pki") + waitForTidyToFinishWithLastRun(t, client, "pki", lastRun) // Cert should no longer exist. resp, err = client.Logical().Read("pki/cert/" + leafSerial) @@ -712,9 +680,8 @@ func TestCertStorageMetrics(t *testing.T) { // Since certificate counts are off by default, we shouldn't see counts in the tidy status tidyStatus, err := client.Logical().Read("pki/tidy-status") - if err != nil { - t.Fatal(err) - } + require.NoError(t, err, "failed reading from tidy-status") + // backendUUID should exist, we need this for metrics backendUUID := tidyStatus.Data["internal_backend_uuid"].(string) // "current_cert_store_count", "current_revoked_cert_count" @@ -759,16 +726,7 @@ func TestCertStorageMetrics(t *testing.T) { testhelpers.EnsureCoresUnsealed(t, cluster) // Wait until a tidy run has completed. - testhelpers.RetryUntil(t, 5*time.Second, func() error { - resp, err = client.Logical().Read("pki/tidy-status") - if err != nil { - return fmt.Errorf("error reading tidy status: %w", err) - } - if finished, ok := resp.Data["time_finished"]; !ok || finished == "" || finished == nil { - return fmt.Errorf("tidy time_finished not run yet: %v", finished) - } - return nil - }) + tidyStatus, _ = waitForTidyToFinish(t, client, "pki") // Since publish_stored_certificate_count_metrics is still false, these metrics should still not exist yet stableMetric = inmemSink.Data() @@ -783,9 +741,6 @@ func TestCertStorageMetrics(t *testing.T) { } // But since certificate counting is on, the metrics should exist on tidyStatus endpoint: - tidyStatus, err = client.Logical().Read("pki/tidy-status") - require.NoError(t, err, "failed reading tidy-status endpoint") - // backendUUID should exist, we need this for metrics backendUUID = tidyStatus.Data["internal_backend_uuid"].(string) // "current_cert_store_count", "current_revoked_cert_count" @@ -905,49 +860,11 @@ func TestCertStorageMetrics(t *testing.T) { t.Logf("%v: Sleeping for %v, leaf certificate expires: %v", time.Now().Format(time.RFC3339), sleepFor, leafCert.NotAfter) time.Sleep(sleepFor) - // Wait for auto-tidy to run afterwards. - var foundTidyRunning string - var foundTidyFinished bool - timeoutChan := time.After(120 * time.Second) - for { - if foundTidyRunning != "" && foundTidyFinished { - break - } - - select { - case <-timeoutChan: - t.Fatalf("expected auto-tidy to run (%v) and finish (%v) before 120 seconds elapsed", foundTidyRunning, foundTidyFinished) - default: - time.Sleep(250 * time.Millisecond) - - resp, err = client.Logical().Read("pki/tidy-status") - require.NoError(t, err) - require.NotNil(t, resp) - require.NotNil(t, resp.Data) - require.NotEmpty(t, resp.Data["state"]) - require.NotEmpty(t, resp.Data["time_started"]) - state := resp.Data["state"].(string) - started := resp.Data["time_started"].(string) - - t.Logf("%v: Resp: %v", time.Now().Format(time.RFC3339), resp.Data) - - // We want the _next_ tidy run after the cert expires. This - // means if we're currently finished when we hit this the - // first time, we want to wait for the next run. - if foundTidyRunning == "" { - foundTidyRunning = started - } else if foundTidyRunning != started && !foundTidyFinished && state == "Finished" { - foundTidyFinished = true - } - } - } + _, lastRun := waitForTidyToFinish(t, client, "pki") + tidyStatus, _ = waitForTidyToFinishWithLastRun(t, client, "pki", lastRun) // After Tidy, Cert Store Count Should Still Be Available, and Be Updated: // Check Metrics After Cert Has Be Created and Revoked - tidyStatus, err = client.Logical().Read("pki/tidy-status") - if err != nil { - t.Fatal(err) - } backendUUID = tidyStatus.Data["internal_backend_uuid"].(string) // "current_cert_store_count", "current_revoked_cert_count" certStoreCount, ok = tidyStatus.Data["current_cert_store_count"] @@ -1007,8 +924,8 @@ func TestTidyAcmeWithBackdate(t *testing.T) { // Create new account with order/cert t.Logf("Testing register on %s", baseAcmeURL) acct, err := acmeClient.Register(testCtx, &acme.Account{}, func(tosURL string) bool { return true }) - t.Logf("got account URI: %v", acct.URI) require.NoError(t, err, "failed registering account") + t.Logf("got account URI: %v", acct.URI) identifiers := []string{"*.localdomain"} order, err := acmeClient.AuthorizeOrder(testCtx, []acme.AuthzID{ {Type: "dns", Value: identifiers[0]}, @@ -1072,7 +989,7 @@ func TestTidyAcmeWithBackdate(t *testing.T) { require.NoError(t, err) // Wait for tidy to finish. - tidyResp := waitForTidyToFinish(t, client, "pki") + tidyResp, _ := waitForTidyToFinish(t, client, "pki") require.Equal(t, tidyResp.Data["acme_orders_deleted_count"], json.Number("1"), "expected to revoke a single ACME order: %v", tidyResp) @@ -1100,7 +1017,7 @@ func TestTidyAcmeWithBackdate(t *testing.T) { require.NoError(t, err) // Wait for tidy to finish. - tidyResp = waitForTidyToFinish(t, client, "pki") + tidyResp, _ = waitForTidyToFinish(t, client, "pki") require.Equal(t, tidyResp.Data["acme_orders_deleted_count"], json.Number("0"), "no ACME orders should have been deleted: %v", tidyResp) require.Equal(t, tidyResp.Data["acme_account_revoked_count"], json.Number("1"), @@ -1164,8 +1081,8 @@ func TestTidyAcmeWithSafetyBuffer(t *testing.T) { // Create new account t.Logf("Testing register on %s", baseAcmeURL) acct, err := acmeClient.Register(testCtx, &acme.Account{}, func(tosURL string) bool { return true }) - t.Logf("got account URI: %v", acct.URI) require.NoError(t, err, "failed registering account") + t.Logf("got account URI: %v", acct.URI) // -> Ensure we see it in storage. Since we don't have direct storage // access, use sys/raw interface. @@ -1187,7 +1104,7 @@ func TestTidyAcmeWithSafetyBuffer(t *testing.T) { require.NoError(t, err) // Wait for tidy to finish. - statusResp := waitForTidyToFinish(t, client, "pki") + statusResp, _ := waitForTidyToFinish(t, client, "pki") require.Equal(t, statusResp.Data["acme_account_revoked_count"], json.Number("1"), "expected to revoke a single ACME account") // Wait for the account to expire. @@ -1359,11 +1276,17 @@ func backDate(original time.Time, change time.Duration) time.Time { return original.Add(-change) } -func waitForTidyToFinish(t *testing.T, client *api.Client, mount string) *api.Secret { +func waitForTidyToFinish(t *testing.T, client *api.Client, mount string) (*api.Secret, time.Time) { + return waitForTidyToFinishWithLastRun(t, client, mount, time.Time{}) +} + +func waitForTidyToFinishWithLastRun(t *testing.T, client *api.Client, mount string, previousFinishTime time.Time) (*api.Secret, time.Time) { + t.Helper() + var statusResp *api.Secret - testhelpers.RetryUntil(t, 5*time.Second, func() error { + var currentFinishTime time.Time + testhelpers.RetryUntil(t, 30*time.Second, func() error { var err error - tidyStatusPath := mount + "/tidy-status" statusResp, err = client.Logical().Read(tidyStatusPath) if err != nil { @@ -1372,8 +1295,21 @@ func waitForTidyToFinish(t *testing.T, client *api.Client, mount string) *api.Se if statusResp == nil { return fmt.Errorf("got nil, nil response from: %s", tidyStatusPath) } - if state, ok := statusResp.Data["state"]; !ok || state == "Running" { - return fmt.Errorf("tidy status state is still running") + if state, ok := statusResp.Data["state"]; !ok || state != "Finished" { + return fmt.Errorf("tidy has not finished got state: %v", state) + } + + if currentFinishTimeRaw, ok := statusResp.Data["time_finished"]; !ok { + return fmt.Errorf("tidy status did not contain a time_finished field") + } else { + if currentFinishTimeStr, ok := currentFinishTimeRaw.(string); !ok { + return fmt.Errorf("tidy status time_finished field was not a string was %T", currentFinishTimeRaw) + } else { + currentFinishTime, err = time.Parse(time.RFC3339, currentFinishTimeStr) + if !currentFinishTime.After(previousFinishTime) { + return fmt.Errorf("tidy status time_finished %v was not after previous time %v", currentFinishTime, previousFinishTime) + } + } } if errorOccurred, ok := statusResp.Data["error"]; !ok || !(errorOccurred == nil || errorOccurred == "") { @@ -1384,5 +1320,5 @@ func waitForTidyToFinish(t *testing.T, client *api.Client, mount string) *api.Se }) t.Logf("got tidy status: %v", statusResp.Data) - return statusResp + return statusResp, currentFinishTime } diff --git a/changelog/18615.txt b/changelog/18615.txt new file mode 100644 index 000000000000..2aa4b32a4cb0 --- /dev/null +++ b/changelog/18615.txt @@ -0,0 +1,3 @@ +```release-note:bug +core: fix issue when attempting to re-bootstrap HA when using Raft as HA but not storage +``` \ No newline at end of file diff --git a/changelog/28126.txt b/changelog/28126.txt new file mode 100644 index 000000000000..5dfb7a864955 --- /dev/null +++ b/changelog/28126.txt @@ -0,0 +1,6 @@ +```release-note:improvement +auto-auth/cert: support watching changes on certificate/key files and notifying the auth handler when `enable_reauth_on_new_credentials` is enabled. +``` +```release-note:improvement +auto-auth: support new config option `enable_reauth_on_new_credentials`, supporting re-authentication when receiving new credential on certain auto-auth types +``` diff --git a/changelog/28519.txt b/changelog/28519.txt new file mode 100644 index 000000000000..9cd11f74ff36 --- /dev/null +++ b/changelog/28519.txt @@ -0,0 +1,3 @@ +```release-note:bug +database/postgresql: Fix potential error revoking privileges in postgresql database secrets engine when a schema contains special characters +``` diff --git a/changelog/28539.txt b/changelog/28539.txt new file mode 100644 index 000000000000..ebbc5476135b --- /dev/null +++ b/changelog/28539.txt @@ -0,0 +1,3 @@ +```release-note:bug +ui: fix `default_role` input missing from oidc auth method configuration form +``` diff --git a/command/agent/config/config.go b/command/agent/config/config.go index d1597cece8fa..7b9a942824ec 100644 --- a/command/agent/config/config.go +++ b/command/agent/config/config.go @@ -129,8 +129,6 @@ type AutoAuth struct { Method *Method `hcl:"-"` Sinks []*Sink `hcl:"sinks"` - // NOTE: This is unsupported outside of testing and may disappear at any - // time. EnableReauthOnNewCredentials bool `hcl:"enable_reauth_on_new_credentials"` } diff --git a/command/agentproxyshared/auth/cert/cert.go b/command/agentproxyshared/auth/cert/cert.go index fabe9a6365fb..b9410c10b9bd 100644 --- a/command/agentproxyshared/auth/cert/cert.go +++ b/command/agentproxyshared/auth/cert/cert.go @@ -5,14 +5,21 @@ package cert import ( "context" + "crypto/tls" + "encoding/hex" "errors" "fmt" "net/http" + "os" + "sync" + "time" "github.com/hashicorp/go-hclog" "github.com/hashicorp/vault/api" "github.com/hashicorp/vault/command/agentproxyshared/auth" "github.com/hashicorp/vault/sdk/helper/consts" + "github.com/hashicorp/vault/sdk/helper/parseutil" + "golang.org/x/crypto/blake2b" ) type certMethod struct { @@ -27,6 +34,14 @@ type certMethod struct { // Client is the cached client to use if cert info was provided. client *api.Client + + stopCh chan struct{} + doneCh chan struct{} + credSuccessGate chan struct{} + ticker *time.Ticker + once *sync.Once + credsFound chan struct{} + latestHash *string } var _ auth.AuthMethodWithClient = &certMethod{} @@ -38,10 +53,17 @@ func NewCertAuthMethod(conf *auth.AuthConfig) (auth.AuthMethod, error) { // Not concerned if the conf.Config is empty as the 'name' // parameter is optional when using TLS Auth - + lastHash := "" c := &certMethod{ logger: conf.Logger, mountPath: conf.MountPath, + + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + credSuccessGate: make(chan struct{}), + once: new(sync.Once), + credsFound: make(chan struct{}), + latestHash: &lastHash, } if conf.Config != nil { @@ -87,6 +109,20 @@ func NewCertAuthMethod(conf *auth.AuthConfig) (auth.AuthMethod, error) { } } + if c.isCertConfigured() && c.reload { + reloadPeriod := time.Minute + if reloadPeriodRaw, ok := conf.Config["reload_period"]; ok { + period, err := parseutil.ParseDurationSecond(reloadPeriodRaw) + if err != nil { + return nil, fmt.Errorf("error parsing 'reload_period' value: %w", err) + } + reloadPeriod = period + } + c.ticker = time.NewTicker(reloadPeriod) + + go c.runWatcher() + } + return c, nil } @@ -103,12 +139,26 @@ func (c *certMethod) Authenticate(_ context.Context, client *api.Client) (string } func (c *certMethod) NewCreds() chan struct{} { - return nil + return c.credsFound } -func (c *certMethod) CredSuccess() {} +func (c *certMethod) CredSuccess() { + c.once.Do(func() { + close(c.credSuccessGate) + }) +} -func (c *certMethod) Shutdown() {} +func (c *certMethod) Shutdown() { + if c.isCertConfigured() && c.reload { + c.ticker.Stop() + close(c.stopCh) + <-c.doneCh + } +} + +func (c *certMethod) isCertConfigured() bool { + return c.caCert != "" || (c.clientKey != "" && c.clientCert != "") +} // AuthClient uses the existing client's address and returns a new client with // the auto-auth method's certificate information if that's provided in its @@ -118,7 +168,7 @@ func (c *certMethod) AuthClient(client *api.Client) (*api.Client, error) { clientToAuth := client - if c.caCert != "" || (c.clientKey != "" && c.clientCert != "") { + if c.isCertConfigured() { // Return cached client if present if c.client != nil && !c.reload { return c.client, nil @@ -141,6 +191,13 @@ func (c *certMethod) AuthClient(client *api.Client) (*api.Client, error) { return nil, err } + // set last hash if load it successfully + if hash, err := c.hashCert(c.clientCert, c.clientKey, c.caCert); err != nil { + return nil, err + } else { + c.latestHash = &hash + } + var err error clientToAuth, err = api.NewClient(config) if err != nil { @@ -156,3 +213,95 @@ func (c *certMethod) AuthClient(client *api.Client) (*api.Client, error) { return clientToAuth, nil } + +// hashCert returns reads and verifies the given cert/key pair and return the hashing result +// in string representation. Otherwise, returns an error. +// As the pair of cert/key and ca cert are optional because they may be configured externally +// or use system default ca bundle, empty paths are simply skipped. +// A valid hashing result means: +// 1. All presented files are readable. +// 2. The client cert/key pair is valid if presented. +// 3. Any presented file in this bundle changed, the hash changes. +func (c *certMethod) hashCert(certFile, keyFile, caFile string) (string, error) { + var buf []byte + if certFile != "" && keyFile != "" { + certPEMBlock, err := os.ReadFile(certFile) + if err != nil { + return "", err + } + c.logger.Debug("Loaded cert file", "file", certFile, "length", len(certPEMBlock)) + + keyPEMBlock, err := os.ReadFile(keyFile) + if err != nil { + return "", err + } + c.logger.Debug("Loaded key file", "file", keyFile, "length", len(keyPEMBlock)) + + // verify + _, err = tls.X509KeyPair(certPEMBlock, keyPEMBlock) + if err != nil { + return "", err + } + c.logger.Debug("The cert/key are valid") + buf = append(certPEMBlock, keyPEMBlock...) + } + + if caFile != "" { + data, err := os.ReadFile(caFile) + if err != nil { + return "", err + } + c.logger.Debug("Loaded ca file", "file", caFile, "length", len(data)) + buf = append(buf, data...) + } + + sum := blake2b.Sum256(buf) + return hex.EncodeToString(sum[:]), nil +} + +// runWatcher uses polling instead of inotify to sense the changes on the cert/key/ca files. +// The reason not to use inotify: +// 1. To not miss any changes, we need to watch the directory instead of files when using inotify. +// 2. These files are not frequently changed/renewed, and they don't need to be reloaded immediately after renewal. +// 3. Some network based filesystem and FUSE don't support inotify. +func (c *certMethod) runWatcher() { + defer close(c.doneCh) + + select { + case <-c.stopCh: + return + + case <-c.credSuccessGate: + // We only start the next loop once we're initially successful, + // since at startup Authenticate will be called, and we don't want + // to end up immediately re-authenticating by having found a new + // value + } + + for { + changed := false + select { + case <-c.stopCh: + return + + case <-c.ticker.C: + c.logger.Debug("Checking if files changed", "cert", c.clientCert, "key", c.clientKey) + hash, err := c.hashCert(c.clientCert, c.clientKey, c.caCert) + // ignore errors in watcher + if err == nil { + c.logger.Debug("hash before/after", "new", hash, "old", *c.latestHash) + changed = *c.latestHash != hash + } else { + c.logger.Warn("hash failed for cert/key files", "err", err) + } + } + + if changed { + c.logger.Info("The cert/key files changed") + select { + case c.credsFound <- struct{}{}: + case <-c.stopCh: + } + } + } +} diff --git a/command/agentproxyshared/auth/cert/cert_test.go b/command/agentproxyshared/auth/cert/cert_test.go index 6a7e4f779e9c..7abf856e8426 100644 --- a/command/agentproxyshared/auth/cert/cert_test.go +++ b/command/agentproxyshared/auth/cert/cert_test.go @@ -5,10 +5,13 @@ package cert import ( "context" + "fmt" "os" "path" + "path/filepath" "reflect" "testing" + "time" "github.com/hashicorp/go-hclog" "github.com/hashicorp/vault/api" @@ -28,6 +31,7 @@ func TestCertAuthMethod_Authenticate(t *testing.T) { if err != nil { t.Fatal(err) } + defer method.Shutdown() client, err := api.NewClient(nil) if err != nil { @@ -65,6 +69,7 @@ func TestCertAuthMethod_AuthClient_withoutCerts(t *testing.T) { if err != nil { t.Fatal(err) } + defer method.Shutdown() client, err := api.NewClient(api.DefaultConfig()) if err != nil { @@ -108,6 +113,7 @@ func TestCertAuthMethod_AuthClient_withCerts(t *testing.T) { if err != nil { t.Fatal(err) } + defer method.Shutdown() client, err := api.NewClient(nil) if err != nil { @@ -134,29 +140,38 @@ func TestCertAuthMethod_AuthClient_withCerts(t *testing.T) { } } -func TestCertAuthMethod_AuthClient_withCertsReload(t *testing.T) { - clientCert, err := os.Open("./test-fixtures/keys/cert.pem") +func copyFile(from, to string) error { + data, err := os.ReadFile(from) if err != nil { - t.Fatal(err) + return err } - defer clientCert.Close() + return os.WriteFile(to, data, 0o600) +} - clientKey, err := os.Open("./test-fixtures/keys/key.pem") - if err != nil { - t.Fatal(err) +// TestCertAuthMethod_AuthClient_withCertsReload makes the file change and ensures the cert auth method deliver the event. +func TestCertAuthMethod_AuthClient_withCertsReload(t *testing.T) { + // Initial the cert/key pair to temp path + certPath := filepath.Join(os.TempDir(), "app.crt") + keyPath := filepath.Join(os.TempDir(), "app.key") + if err := copyFile("./test-fixtures/keys/cert.pem", certPath); err != nil { + t.Fatal("copy cert file failed", err) } - - defer clientKey.Close() + defer os.Remove(certPath) + if err := copyFile("./test-fixtures/keys/key.pem", keyPath); err != nil { + t.Fatal("copy key file failed", err) + } + defer os.Remove(keyPath) config := &auth.AuthConfig{ Logger: hclog.NewNullLogger(), MountPath: "cert-test", Config: map[string]interface{}{ - "name": "with-certs-reloaded", - "client_cert": clientCert.Name(), - "client_key": clientKey.Name(), - "reload": true, + "name": "with-certs-reloaded", + "client_cert": certPath, + "client_key": keyPath, + "reload": true, + "reload_period": 1, }, } @@ -164,6 +179,7 @@ func TestCertAuthMethod_AuthClient_withCertsReload(t *testing.T) { if err != nil { t.Fatal(err) } + defer method.Shutdown() client, err := api.NewClient(nil) if err != nil { @@ -188,4 +204,113 @@ func TestCertAuthMethod_AuthClient_withCertsReload(t *testing.T) { if reloadedClient == clientToUse { t.Fatal("expected client from AuthClient to return back a new client") } + + method.CredSuccess() + // Only make a change to the cert file, it doesn't match the key file so the client won't pick and load them. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + if err = copyFile("./test-fixtures/keys/cert1.pem", certPath); err != nil { + t.Fatal("update cert file failed", err) + } + + select { + case <-ctx.Done(): + case <-method.NewCreds(): + cancel() + t.Fatal("malformed cert should not be observed as a change") + } + + // Make a change to the key file and now they are good to be picked. + if err = copyFile("./test-fixtures/keys/key1.pem", keyPath); err != nil { + t.Fatal("update key file failed", err) + } + ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second) + select { + case <-ctx.Done(): + t.Fatal("failed to watch the cert change: timeout") + case <-method.NewCreds(): + cancel() + } +} + +// TestCertAuthMethod_hashCert_withEmptyPaths tests hashCert() if it works well with optional options. +func TestCertAuthMethod_hashCert_withEmptyPaths(t *testing.T) { + c := &certMethod{ + logger: hclog.NewNullLogger(), + } + + // It skips empty file paths + sum, err := c.hashCert("", "", "") + if sum == "" || err != nil { + t.Fatal("hashCert() should skip empty file paths and succeed.") + } + emptySum := sum + + // Only present ca cert + sum, err = c.hashCert("", "", "./test-fixtures/root/rootcacert.pem") + if sum == "" || err != nil { + t.Fatal("hashCert() should succeed when only present ca cert.") + } + + // Only present client cert/key + sum, err = c.hashCert("./test-fixtures/keys/cert.pem", "./test-fixtures/keys/key.pem", "") + if sum == "" || err != nil { + fmt.Println(sum, err) + t.Fatal("hashCert() should succeed when only present client cert/key.") + } + + // The client cert/key should be presented together or will be skipped + sum, err = c.hashCert("./test-fixtures/keys/cert.pem", "", "") + if sum == "" || err != nil { + t.Fatal("hashCert() should succeed when only present client cert.") + } else if sum != emptySum { + t.Fatal("hashCert() should skip the client cert/key when only present client cert.") + } +} + +// TestCertAuthMethod_hashCert_withInvalidClientCert adds test cases for invalid input for hashCert(). +func TestCertAuthMethod_hashCert_withInvalidClientCert(t *testing.T) { + c := &certMethod{ + logger: hclog.NewNullLogger(), + } + + // With mismatched cert/key pair + sum, err := c.hashCert("./test-fixtures/keys/cert1.pem", "./test-fixtures/keys/key.pem", "") + if sum != "" || err == nil { + t.Fatal("hashCert() should fail with invalid client cert.") + } + + // With non-existed paths + sum, err = c.hashCert("./test-fixtures/keys/cert2.pem", "./test-fixtures/keys/key.pem", "") + if sum != "" || err == nil { + t.Fatal("hashCert() should fail with non-existed client cert path.") + } +} + +// TestCertAuthMethod_hashCert_withChange tests hashCert() if it detects changes from both client cert/key and ca cert. +func TestCertAuthMethod_hashCert_withChange(t *testing.T) { + c := &certMethod{ + logger: hclog.NewNullLogger(), + } + + // A good first case. + sum, err := c.hashCert("./test-fixtures/keys/cert.pem", "./test-fixtures/keys/key.pem", "./test-fixtures/root/rootcacert.pem") + if sum == "" || err != nil { + t.Fatal("hashCert() shouldn't fail with a valid pair of cert/key.") + } + + // Only change the ca cert from the first case. + sum1, err := c.hashCert("./test-fixtures/keys/cert.pem", "./test-fixtures/keys/key.pem", "./test-fixtures/keys/cert.pem") + if sum1 == "" || err != nil { + t.Fatal("hashCert() shouldn't fail with valid pair of cert/key.") + } else if sum == sum1 { + t.Fatal("The hash should be different with a different ca cert.") + } + + // Only change the cert/key pair from the first case. + sum2, err := c.hashCert("./test-fixtures/keys/cert1.pem", "./test-fixtures/keys/key1.pem", "./test-fixtures/root/rootcacert.pem") + if sum2 == "" || err != nil { + t.Fatal("hashCert() shouldn't fail with a valid cert/key pair") + } else if sum == sum2 || sum1 == sum2 { + t.Fatal("The hash should be different with a different pair of cert/key.") + } } diff --git a/command/agentproxyshared/auth/cert/test-fixtures/keys/cert1.pem b/command/agentproxyshared/auth/cert/test-fixtures/keys/cert1.pem new file mode 100644 index 000000000000..01afb2157e37 --- /dev/null +++ b/command/agentproxyshared/auth/cert/test-fixtures/keys/cert1.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICrTCCAZUCCQDDXho7UXdaIjANBgkqhkiG9w0BAQsFADAWMRQwEgYDVQQDEwtl +eGFtcGxlLmNvbTAeFw0yNDA4MTcwOTE0MDRaFw0zNDA4MTUwOTE0MDRaMBsxGTAX +BgNVBAMMEGNlcnQuZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQDDSMAi8aL1XLCRrPl8KjJcH/pJe9QJtzUIU3T9tfj+Eq8yMUbFu+so +ec+knsxTi5zN7wq1/t9B9tIvDVG0C9T7BbhX2dYPNC1oY7DtdI3KqA76Z78v533Y +p/WFMHn9X1v0g7qOHm9Y7V6oHg7m+ICq84fORbmfgNW/tPNqTJRU4wyzlIPw1Toi +9awHMZHZmbjUwFgSQ8TOXgZfWo1ZmbOFY2epBIRCapsYpJgwKXy1UjIfQIQ6e6xm +KbKQ/IIeuufo5U8vYV91nGNOVkieeGQ8vmVa1f/oyFfChCRR+aLCqbUGfJWzdicm +eqyQVmPqJxTFuh7WMq+cOX5A068sYj0FAgMBAAEwDQYJKoZIhvcNAQELBQADggEB +AFtUgRS+OZXmDmhIiaw4OrMruz3N2PCjWo/y+rK5gECuApGv7may3k9E65yRUvBb +Ch68y1TMr+7J0MDl1CIbJUnLJkmcID+IvLVS3hVJ9H0raP6epDRvfkM3Xc/RwNgS +PS1H1K8oxDPoo4an1yc6UoKng5KCAUYN+8dR9iVpCIPzRm0LSDIqMyamxoeNLfrO +Nta+sKu1iS/MHy/MVLqyRDwTP2DnfYJTvhQDK5Y5bi7Chkv7g3ug/o2RZ38rRiRd +Os90dDmTCgnYBSJtfKWF5gSnzP+OTs6Yb6KOIY7gLY/r1PBPabSuAnRMS/iTi6tq +l91Cs+vnv6HNcZsGphoQJq8= +-----END CERTIFICATE----- diff --git a/command/agentproxyshared/auth/cert/test-fixtures/keys/key1.pem b/command/agentproxyshared/auth/cert/test-fixtures/keys/key1.pem new file mode 100644 index 000000000000..ea07f86416f2 --- /dev/null +++ b/command/agentproxyshared/auth/cert/test-fixtures/keys/key1.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDDSMAi8aL1XLCR +rPl8KjJcH/pJe9QJtzUIU3T9tfj+Eq8yMUbFu+soec+knsxTi5zN7wq1/t9B9tIv +DVG0C9T7BbhX2dYPNC1oY7DtdI3KqA76Z78v533Yp/WFMHn9X1v0g7qOHm9Y7V6o +Hg7m+ICq84fORbmfgNW/tPNqTJRU4wyzlIPw1Toi9awHMZHZmbjUwFgSQ8TOXgZf +Wo1ZmbOFY2epBIRCapsYpJgwKXy1UjIfQIQ6e6xmKbKQ/IIeuufo5U8vYV91nGNO +VkieeGQ8vmVa1f/oyFfChCRR+aLCqbUGfJWzdicmeqyQVmPqJxTFuh7WMq+cOX5A +068sYj0FAgMBAAECggEAP1vIMs4xL+g9xVXYsAdExYz+eH77gZd2Vlg1eedjfJN1 +UhSYwKjCmCRFUUTQSD7gxhPLZtblepJpCSkKHB9Gn5bwg1hC0jX8kYTer3wEUP8L +tQSaDCHQO83qo6bhvWoF/KQMj/Wh7Lk+3864yQlRPaW7pxoKKozzTLqZyyBDc/KR +YaUco+9NFqClHd/TRehoykYa7OvNVJjBDxTnnxijE0d5w83rP+wDJczhe/Xn/0f1 +Q7JFa4NpKmLEXj93GZiteloE80AbVnMiIemGB8ZZGHcySiib3wzuLk32dLS8zguU +gp3E1FhL5xI7gsS7ClA/S6+tK1c46FzQYuIA105tAQKBgQDhnoxCW0N8xV5I1tje +Q1AW+tMVyO6yaREFuoz6tE6AZ1drywyHdqia/QNbXkdllYVQDMlszQ3tmZ8RT/+5 +NdJ+LnNag8T6PaN3AtXAf0uveCL1et5ffWuRicesJCCJ10ESFQaVccZEqhJhtnQk +giqICNHV0dWIEVsZGi5R4sA0wQKBgQDdlICpZud2SLrSx00Fb6TfZumxWjDwDb9D +avoQJb376pg1qpAh53hUJbHWPlspeG/k24J0oRrnb3aln8QS21qVov90YllEWwnO +xebYgdjvfOIZ1b8vJ2/UkfLX9Xa9KuzvGpv4BSNOZ8UNHI6Dj/eFmWP+q/a3vzJT +rEgoC1xFRQKBgQCGkZtUxLxnAg1vYn3ta7asTiRyvOrqDNKzWQZXTg34dirlRzGM +5pBACSLkb0Ika98c1NObCl8BVXxTxiRfoqOO0UPKPAfTvcnu5Qj7DLHm0cAALK3P +xK3RG521pcKmlHXiRBouLrM0J0BZeYqib+TQSHpnjwVOaBOu0DfKbXV4wQKBgAaU +VEWzcogGnNWJaXYR3Jltmt7TSMS4A8fis04rcLq8OozNZb47+0y0WdV8wIQ4uUnY +YsVHy1635pQAbHgK32O2FVPFX9UxxtbG9ZXUNTbXRHdz61thFmb/dnCHL2FqluJ6 +rcrtjCDV3/oFsQ2jBryG03tKa+cE3F+zq+jUfYbpAoGAauV0h6kqS5mF+sa3F+I1 +zIZ7k81r5csZXkgQ6HphIAvo5NSv7H1jeSkGbZmg29irReggZLsy6nU4B4ZVt1p9 +GIsLgJfkCkHT+Vf0ipygAwFnbEUKqs6A/D0EUtAF2Oc7nVl0NIX+9LmEx7Dwl34i +bTTPVgw5bid08eiN46NN9J4= +-----END PRIVATE KEY----- diff --git a/command/proxy/config/config.go b/command/proxy/config/config.go index 2f5f5b320181..e0a9080cc38e 100644 --- a/command/proxy/config/config.go +++ b/command/proxy/config/config.go @@ -117,8 +117,6 @@ type AutoAuth struct { Method *Method `hcl:"-"` Sinks []*Sink `hcl:"sinks"` - // NOTE: This is unsupported outside of testing and may disappear at any - // time. EnableReauthOnNewCredentials bool `hcl:"enable_reauth_on_new_credentials"` } diff --git a/command/server.go b/command/server.go index 5aba92cbd36d..52013aab2bbc 100644 --- a/command/server.go +++ b/command/server.go @@ -1721,6 +1721,7 @@ func (c *ServerCommand) Run(args []string) int { // Notify systemd that the server has completed reloading config c.notifySystemd(systemd.SdNotifyReady) case <-c.SigUSR2Ch: + c.logger.Info("Received SIGUSR2, dumping goroutines. This is expected behavior. Vault continues to run normally.") logWriter := c.logger.StandardWriter(&hclog.StandardLoggerOptions{}) pprof.Lookup("goroutine").WriteTo(logWriter, 2) diff --git a/plugins/database/postgresql/postgresql.go b/plugins/database/postgresql/postgresql.go index 004ca27dfc2b..ffe460f45c53 100644 --- a/plugins/database/postgresql/postgresql.go +++ b/plugins/database/postgresql/postgresql.go @@ -529,7 +529,7 @@ func (p *PostgreSQL) defaultDeleteUser(ctx context.Context, username string) err } revocationStmts = append(revocationStmts, fmt.Sprintf( `REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA %s FROM %s;`, - (schema), + dbutil.QuoteIdentifier(schema), dbutil.QuoteIdentifier(username))) revocationStmts = append(revocationStmts, fmt.Sprintf( diff --git a/ui/app/utils/openapi-helpers.ts b/ui/app/utils/openapi-helpers.ts index 20844fa4bd77..91675bf580da 100644 --- a/ui/app/utils/openapi-helpers.ts +++ b/ui/app/utils/openapi-helpers.ts @@ -136,7 +136,6 @@ export function filterPathsByItemType(pathInfo: PathsInfo, itemType: string): Pa * This object maps model names to the openAPI path that hydrates the model, given the backend path. */ const OPENAPI_POWERED_MODELS = { - 'role-ssh': (backend: string) => `/v1/${backend}/roles/example?help=1`, 'auth-config/azure': (backend: string) => `/v1/auth/${backend}/config?help=1`, 'auth-config/cert': (backend: string) => `/v1/auth/${backend}/config?help=1`, 'auth-config/gcp': (backend: string) => `/v1/auth/${backend}/config?help=1`, @@ -144,18 +143,20 @@ const OPENAPI_POWERED_MODELS = { 'auth-config/jwt': (backend: string) => `/v1/auth/${backend}/config?help=1`, 'auth-config/kubernetes': (backend: string) => `/v1/auth/${backend}/config?help=1`, 'auth-config/ldap': (backend: string) => `/v1/auth/${backend}/config?help=1`, + 'auth-config/oidc': (backend: string) => `/v1/auth/${backend}/config?help=1`, 'auth-config/okta': (backend: string) => `/v1/auth/${backend}/config?help=1`, 'auth-config/radius': (backend: string) => `/v1/auth/${backend}/config?help=1`, 'kmip/config': (backend: string) => `/v1/${backend}/config?help=1`, 'kmip/role': (backend: string) => `/v1/${backend}/scope/example/role/example?help=1`, - 'pki/role': (backend: string) => `/v1/${backend}/roles/example?help=1`, - 'pki/tidy': (backend: string) => `/v1/${backend}/config/auto-tidy?help=1`, - 'pki/sign-intermediate': (backend: string) => `/v1/${backend}/issuer/example/sign-intermediate?help=1`, 'pki/certificate/generate': (backend: string) => `/v1/${backend}/issue/example?help=1`, 'pki/certificate/sign': (backend: string) => `/v1/${backend}/sign/example?help=1`, 'pki/config/acme': (backend: string) => `/v1/${backend}/config/acme?help=1`, 'pki/config/cluster': (backend: string) => `/v1/${backend}/config/cluster?help=1`, 'pki/config/urls': (backend: string) => `/v1/${backend}/config/urls?help=1`, + 'pki/role': (backend: string) => `/v1/${backend}/roles/example?help=1`, + 'pki/sign-intermediate': (backend: string) => `/v1/${backend}/issuer/example/sign-intermediate?help=1`, + 'pki/tidy': (backend: string) => `/v1/${backend}/config/auto-tidy?help=1`, + 'role-ssh': (backend: string) => `/v1/${backend}/roles/example?help=1`, }; export function getHelpUrlForModel(modelType: string, backend: string) { diff --git a/ui/config/deprecation-workflow.js b/ui/config/deprecation-workflow.js index 7ab1bcf1dc0a..945f70b95fa1 100644 --- a/ui/config/deprecation-workflow.js +++ b/ui/config/deprecation-workflow.js @@ -14,7 +14,6 @@ self.deprecationWorkflow.config = { workflow: [ { handler: 'silence', matchId: 'ember-engines.deprecation-router-service-from-host' }, // ember-data - { handler: 'silence', matchId: 'ember-data:deprecate-early-static' }, // decorator tests { handler: 'silence', matchId: 'ember-data:deprecate-promise-proxies' }, // Transform secrets { handler: 'silence', matchId: 'ember-data:no-a-with-array-like' }, // MFA { handler: 'silence', matchId: 'ember-data:deprecate-promise-many-array-behaviors' }, // MFA diff --git a/ui/lib/core/addon/components/masked-input.hbs b/ui/lib/core/addon/components/masked-input.hbs index 833b940a259d..5cb75aa2da2b 100644 --- a/ui/lib/core/addon/components/masked-input.hbs +++ b/ui/lib/core/addon/components/masked-input.hbs @@ -32,7 +32,7 @@ aria-label={{or @name "masked input"}} {{on "change" this.onChange}} {{on "keyup" (fn this.handleKeyUp @name)}} - data-test-textarea={{or @name ""}} + data-test-input={{or @name ""}} /> {{/if}} {{#if @allowCopy}} diff --git a/ui/package.json b/ui/package.json index b2c2dce2289d..40196e6f30e6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -104,7 +104,7 @@ "deepmerge": "^4.0.0", "doctoc": "^2.2.0", "dompurify": "^3.0.2", - "ember-a11y-testing": "^6.1.1", + "ember-a11y-testing": "^7.0.1", "ember-basic-dropdown": "^8.0.4", "ember-cli": "~5.4.2", "ember-cli-babel": "^8.2.0", @@ -190,6 +190,7 @@ "resolutions": { "ansi-html": "^0.0.8", "async": "^2.6.4", + "body-parser": "^1.20.3", "braces": "^3.0.3", "eslint-utils": "^1.4.1", "highlight.js": "^10.4.1", diff --git a/ui/tests/acceptance/auth-list-test.js b/ui/tests/acceptance/auth-list-test.js index 2d8caf76f7b7..38584f6b063e 100644 --- a/ui/tests/acceptance/auth-list-test.js +++ b/ui/tests/acceptance/auth-list-test.js @@ -18,8 +18,6 @@ import { GENERAL } from 'vault/tests/helpers/general-selectors'; const SELECTORS = { backendLink: (path) => `[data-test-auth-backend-link="${path}"]`, createUser: '[data-test-entity-create-link="user"]', - input: (attr) => `[data-test-input="${attr}"]`, - password: '[data-test-textarea]', saveBtn: '[data-test-save-config]', methods: '[data-test-access-methods] a', listItem: '[data-test-list-item-content]', @@ -49,8 +47,8 @@ module('Acceptance | auth backend list', function (hooks) { await click(SELECTORS.backendLink(this.path1)); assert.dom(GENERAL.emptyStateTitle).exists('shows empty state'); await click(SELECTORS.createUser); - await fillIn(SELECTORS.input('username'), this.user1); - await fillIn(SELECTORS.password, this.user1); + await fillIn(GENERAL.inputByAttr('username'), this.user1); + await fillIn(GENERAL.inputByAttr('password'), this.user1); await click(SELECTORS.saveBtn); assert.strictEqual(currentURL(), `/vault/access/${this.path1}/item/user`); @@ -61,8 +59,8 @@ module('Acceptance | auth backend list', function (hooks) { await click(SELECTORS.backendLink(this.path2)); assert.dom(GENERAL.emptyStateTitle).exists('shows empty state'); await click(SELECTORS.createUser); - await fillIn(SELECTORS.input('username'), this.user2); - await fillIn(SELECTORS.password, this.user2); + await fillIn(GENERAL.inputByAttr('username'), this.user2); + await fillIn(GENERAL.inputByAttr('password'), this.user2); await click(SELECTORS.saveBtn); assert.strictEqual(currentURL(), `/vault/access/${this.path2}/item/user`); // Confirm that the user was created. There was a bug where the apiPath was not being updated when toggling between auth routes. diff --git a/ui/tests/acceptance/auth/enable-tune-form-test.js b/ui/tests/acceptance/auth/enable-tune-form-test.js new file mode 100644 index 000000000000..07d192fbe8e8 --- /dev/null +++ b/ui/tests/acceptance/auth/enable-tune-form-test.js @@ -0,0 +1,216 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; +import { v4 as uuidv4 } from 'uuid'; + +import { login } from 'vault/tests/helpers/auth/auth-helpers'; +import { visit } from '@ember/test-helpers'; +import { deleteAuthCmd, runCmd } from 'vault/tests/helpers/commands'; +import testHelper from './test-helper'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; + +// These models use openAPI so we assert the form inputs using an acceptance test +// The default selector is to use GENERAL.inputByAttr() +// custom fields should be added to the this.customSelectorss object +module('Acceptance | auth enable tune form test', function (hooks) { + setupApplicationTest(hooks); + hooks.beforeEach(async function () { + // these tend to be the same across models because they share the same mount-config model + // if necessary, they can be overridden in the individual module + this.mountFields = [ + 'path', + 'description', + 'local', + 'sealWrap', + 'config.listingVisibility', + 'config.defaultLeaseTtl', + 'config.maxLeaseTtl', + 'config.tokenType', + 'config.auditNonHmacRequestKeys', + 'config.auditNonHmacResponseKeys', + 'config.passthroughRequestHeaders', + 'config.allowedResponseHeaders', + 'config.pluginVersion', + ]; + }); + + module('azure', function (hooks) { + hooks.beforeEach(async function () { + this.type = 'azure'; + this.path = `${this.type}-${uuidv4()}`; + this.tuneFields = [ + 'environment', + 'identityTokenAudience', + 'identityTokenTtl', + 'maxRetries', + 'maxRetryDelay', + 'resource', + 'retryDelay', + 'rootPasswordTtl', + 'tenantId', + ]; + this.tuneToggles = { 'Azure Options': ['clientId', 'clientSecret'] }; + await login(); + return visit('/vault/settings/auth/enable'); + }); + hooks.afterEach(async function () { + await runCmd(deleteAuthCmd(this.path), false); + }); + testHelper(test); + }); + + module('jwt', function (hooks) { + hooks.beforeEach(async function () { + this.type = 'jwt'; + this.path = `${this.type}-${uuidv4()}`; + this.customSelectors = { + providerConfig: `${GENERAL.fieldByAttr('providerConfig')} textarea`, + }; + this.tuneFields = [ + 'defaultRole', + 'jwksCaPem', + 'jwksUrl', + 'namespaceInState', + 'oidcDiscoveryUrl', + 'oidcResponseMode', + 'oidcResponseTypes', + 'providerConfig', + 'unsupportedCriticalCertExtensions', + ]; + this.tuneToggles = { + 'JWT Options': [ + 'oidcClientId', + 'oidcClientSecret', + 'oidcDiscoveryCaPem', + 'jwtValidationPubkeys', + 'jwtSupportedAlgs', + 'boundIssuer', + ], + }; + await login(); + return visit('/vault/settings/auth/enable'); + }); + hooks.afterEach(async function () { + await runCmd(deleteAuthCmd(this.path), false); + }); + testHelper(test); + }); + + module('ldap', function (hooks) { + hooks.beforeEach(async function () { + this.type = 'ldap'; + this.path = `${this.type}-${uuidv4()}`; + this.tuneFields = [ + 'url', + 'caseSensitiveNames', + 'connectionTimeout', + 'dereferenceAliases', + 'maxPageSize', + 'passwordPolicy', + 'requestTimeout', + 'tokenBoundCidrs', + 'tokenExplicitMaxTtl', + 'tokenMaxTtl', + 'tokenNoDefaultPolicy', + 'tokenNumUses', + 'tokenPeriod', + 'tokenPolicies', + 'tokenTtl', + 'tokenType', + 'usePre111GroupCnBehavior', + 'usernameAsAlias', + ]; + this.tuneToggles = { + 'LDAP Options': [ + 'starttls', + 'insecureTls', + 'discoverdn', + 'denyNullBind', + 'tlsMinVersion', + 'tlsMaxVersion', + 'certificate', + 'clientTlsCert', + 'clientTlsKey', + 'userattr', + 'upndomain', + 'anonymousGroupSearch', + ], + 'Customize User Search': ['binddn', 'userdn', 'bindpass', 'userfilter'], + 'Customize Group Membership Search': ['groupfilter', 'groupattr', 'groupdn', 'useTokenGroups'], + }; + await login(); + return visit('/vault/settings/auth/enable'); + }); + hooks.afterEach(async function () { + await runCmd(deleteAuthCmd(this.path), false); + }); + testHelper(test); + }); + + module('oidc', function (hooks) { + hooks.beforeEach(async function () { + this.type = 'oidc'; + this.path = `${this.type}-${uuidv4()}`; + this.customSelectors = { + providerConfig: `${GENERAL.fieldByAttr('providerConfig')} textarea`, + }; + this.tuneFields = [ + 'oidcDiscoveryUrl', + 'defaultRole', + 'jwksCaPem', + 'jwksUrl', + 'oidcResponseMode', + 'oidcResponseTypes', + 'namespaceInState', + 'providerConfig', + 'unsupportedCriticalCertExtensions', + ]; + this.tuneToggles = { + 'OIDC Options': [ + 'oidcClientId', + 'oidcClientSecret', + 'oidcDiscoveryCaPem', + 'jwtValidationPubkeys', + 'jwtSupportedAlgs', + 'boundIssuer', + ], + }; + await login(); + return visit('/vault/settings/auth/enable'); + }); + hooks.afterEach(async function () { + await runCmd(deleteAuthCmd(this.path), false); + }); + testHelper(test); + }); + + module('okta', function (hooks) { + hooks.beforeEach(async function () { + this.type = 'okta'; + this.path = `${this.type}-${uuidv4()}`; + this.tuneFields = [ + 'orgName', + 'tokenBoundCidrs', + 'tokenExplicitMaxTtl', + 'tokenMaxTtl', + 'tokenNoDefaultPolicy', + 'tokenNumUses', + 'tokenPeriod', + 'tokenPolicies', + 'tokenTtl', + 'tokenType', + ]; + this.tuneToggles = { Options: ['apiToken', 'baseUrl', 'bypassOktaMfa'] }; + await login(); + return visit('/vault/settings/auth/enable'); + }); + hooks.afterEach(async function () { + await runCmd(deleteAuthCmd(this.path), false); + }); + testHelper(test); + }); +}); diff --git a/ui/tests/acceptance/auth/test-helper.js b/ui/tests/acceptance/auth/test-helper.js new file mode 100644 index 000000000000..dc0c7b8fff45 --- /dev/null +++ b/ui/tests/acceptance/auth/test-helper.js @@ -0,0 +1,49 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { click, currentURL, fillIn } from '@ember/test-helpers'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; + +const SELECTORS = { + mountType: (name) => `[data-test-mount-type="${name}"]`, + submit: '[data-test-mount-submit]', +}; + +const assertFields = (assert, fields, customSelectors = {}) => { + fields.forEach((param) => { + if (Object.keys(customSelectors).includes(param)) { + assert.dom(customSelectors[param]).exists(); + } else { + assert.dom(GENERAL.inputByAttr(param)).exists(); + } + }); +}; +export default (test) => { + test('it renders mount fields', async function (assert) { + await click(SELECTORS.mountType(this.type)); + await click(GENERAL.toggleGroup('Method Options')); + assertFields(assert, this.mountFields, this.customSelectors); + }); + + test('it renders tune fields', async function (assert) { + // enable auth method to check tune fields + await click(SELECTORS.mountType(this.type)); + await fillIn(GENERAL.inputByAttr('path'), this.path); + await click(SELECTORS.submit); + assert.strictEqual( + currentURL(), + `/vault/settings/auth/configure/${this.path}/configuration`, + `${this.type}: it mounts navigates to tune form` + ); + + assertFields(assert, this.tuneFields, this.customSelectors); + + for (const toggle in this.tuneToggles) { + const fields = this.tuneToggles[toggle]; + await click(GENERAL.toggleGroup(toggle)); + assertFields(assert, fields, this.customSelectors); + } + }); +}; diff --git a/ui/tests/acceptance/mfa-method-test.js b/ui/tests/acceptance/mfa-method-test.js index dbe65eb487f5..499b1d028515 100644 --- a/ui/tests/acceptance/mfa-method-test.js +++ b/ui/tests/acceptance/mfa-method-test.js @@ -11,6 +11,7 @@ import { setupMirage } from 'ember-cli-mirage/test-support'; import mfaConfigHandler from 'vault/mirage/handlers/mfa-config'; import { Response } from 'miragejs'; import { underscore } from '@ember/string'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; module('Acceptance | mfa-method', function (hooks) { setupApplicationTest(hooks); @@ -181,17 +182,10 @@ module('Acceptance | mfa-method', function (hooks) { .dom('[data-test-inline-error-message]') .exists({ count: required.length }, `Required field validations display for ${type}`); - for (const [i, field] of required.entries()) { - let inputType = 'input'; - // this is less than ideal but updating the test selectors in masked-input break a bunch of tests - // add value to the masked input text area data-test attributes for selection - if (['secret_key', 'integration_key'].includes(field)) { - inputType = 'textarea'; - const textareas = this.element.querySelectorAll('[data-test-textarea]'); - textareas[i].setAttribute('data-test-textarea', field); - } - await fillIn(`[data-test-${inputType}="${field}"]`, 'foo'); + for (const field of required) { + await fillIn(GENERAL.inputByAttr(field), 'foo'); } + await click('[data-test-mfa-create-save]'); assert.strictEqual( currentRouteName(), diff --git a/ui/tests/acceptance/reset-password-test.js b/ui/tests/acceptance/reset-password-test.js index 51a4a9ce3f36..731284df2dd4 100644 --- a/ui/tests/acceptance/reset-password-test.js +++ b/ui/tests/acceptance/reset-password-test.js @@ -59,11 +59,11 @@ module('Acceptance | reset password', function (hooks) { ); assert.dom('[data-test-title]').hasText('Reset password', 'page title'); - await fillIn('[data-test-textarea]', 'newpassword'); + await fillIn('[data-test-input="reset-password"]', 'newpassword'); await click('[data-test-reset-password-save]'); await waitFor('[data-test-flash-message]'); assert.dom('[data-test-flash-message]').hasText(`Success ${SUCCESS_MESSAGE}`); - assert.dom('[data-test-textarea]').hasValue('', 'Resets input after save'); + assert.dom('[data-test-input="reset-password"]').hasValue('', 'Resets input after save'); }); test('allows password reset for userpass users logged in via tab', async function (assert) { @@ -91,10 +91,10 @@ module('Acceptance | reset password', function (hooks) { ); assert.dom('[data-test-title]').hasText('Reset password', 'page title'); - await fillIn('[data-test-textarea]', 'newpassword'); + await fillIn('[data-test-input="reset-password"]', 'newpassword'); await click('[data-test-reset-password-save]'); await waitFor('[data-test-flash-message]'); assert.dom('[data-test-flash-message]').hasText(`Success ${SUCCESS_MESSAGE}`); - assert.dom('[data-test-textarea]').hasValue('', 'Resets input after save'); + assert.dom('[data-test-input="reset-password"]').hasValue('', 'Resets input after save'); }); }); diff --git a/ui/tests/acceptance/secrets/backend/aws/aws-configuration-test.js b/ui/tests/acceptance/secrets/backend/aws/aws-configuration-test.js index 9430ccac1442..605bd26b3c1c 100644 --- a/ui/tests/acceptance/secrets/backend/aws/aws-configuration-test.js +++ b/ui/tests/acceptance/secrets/backend/aws/aws-configuration-test.js @@ -368,11 +368,7 @@ module('Acceptance | aws | configuration', function (hooks) { // check all the form fields are present await click(GENERAL.toggleGroup('Root config options')); for (const key of expectedConfigKeys('aws-root-create')) { - if (key === 'secretKey') { - assert.dom(GENERAL.maskedInput(key)).exists(`${key} shows for root section.`); - } else { - assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for root section.`); - } + assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for root section.`); } for (const key of expectedConfigKeys('aws-lease')) { assert.dom(`[data-test-ttl-form-label="${key}"]`).exists(`${key} shows for Lease section.`); diff --git a/ui/tests/acceptance/secrets/backend/ssh/configuration-test.js b/ui/tests/acceptance/secrets/backend/ssh/configuration-test.js index ecb0ce9b3267..27d3c31ffca8 100644 --- a/ui/tests/acceptance/secrets/backend/ssh/configuration-test.js +++ b/ui/tests/acceptance/secrets/backend/ssh/configuration-test.js @@ -86,7 +86,7 @@ module('Acceptance | ssh | configuration', function (hooks) { `/vault/secrets/${sshPath}/configuration/edit`, 'after deleting public key stays on edit page' ); - assert.dom(GENERAL.maskedInput('privateKey')).hasNoText('Private key is empty and reset'); + assert.dom(GENERAL.inputByAttr('privateKey')).hasNoText('Private key is empty and reset'); assert.dom(GENERAL.inputByAttr('publicKey')).hasNoText('Public key is empty and reset'); assert.dom(GENERAL.inputByAttr('generateSigningKey')).isChecked('Generate signing key is checked'); await click(SES.viewBackend); diff --git a/ui/tests/acceptance/sync/secrets/destination-test.js b/ui/tests/acceptance/sync/secrets/destination-test.js index 82d5cd33f84e..385a139a89b2 100644 --- a/ui/tests/acceptance/sync/secrets/destination-test.js +++ b/ui/tests/acceptance/sync/secrets/destination-test.js @@ -87,7 +87,7 @@ module('Acceptance | sync | destination (singular)', function (hooks) { await visit('vault/sync/secrets/destinations/vercel-project/destination-vercel/edit'); await click(ts.enableField('accessToken')); - await fillIn(ts.maskedInput('accessToken'), 'foobar'); + await fillIn(GENERAL.inputByAttr('accessToken'), 'foobar'); await click(ts.saveButton); await click(ts.toolbar('Edit destination')); await click(ts.saveButton); diff --git a/ui/tests/helpers/general-selectors.ts b/ui/tests/helpers/general-selectors.ts index 90f5c35e1c83..deff4ef53fc3 100644 --- a/ui/tests/helpers/general-selectors.ts +++ b/ui/tests/helpers/general-selectors.ts @@ -94,7 +94,6 @@ export const GENERAL = { navLink: (label: string) => `[data-test-sidebar-nav-link="${label}"]`, cancelButton: '[data-test-cancel]', saveButton: '[data-test-save]', - maskedInput: (name: string) => `[data-test-textarea="${name}"]`, codemirror: `[data-test-component="code-mirror-modifier"]`, codemirrorTextarea: `[data-test-component="code-mirror-modifier"] textarea`, }; diff --git a/ui/tests/helpers/kv/kv-selectors.js b/ui/tests/helpers/kv/kv-selectors.js index 718cfcf371b4..77e7b91416d9 100644 --- a/ui/tests/helpers/kv/kv-selectors.js +++ b/ui/tests/helpers/kv/kv-selectors.js @@ -110,7 +110,7 @@ export const FORM = { kvRow: '[data-test-kv-row]', keyInput: (idx = 0) => `[data-test-kv-key="${idx}"]`, valueInput: (idx = 0) => `[data-test-kv-value="${idx}"]`, - maskedValueInput: (idx = 0) => `[data-test-kv-value="${idx}"] [data-test-textarea]`, + maskedValueInput: (idx = 0) => `[data-test-kv-value="${idx}"] [data-test-input]`, addRow: (idx = 0) => `[data-test-kv-add-row="${idx}"]`, deleteRow: (idx = 0) => `[data-test-kv-delete-row="${idx}"]`, // diff --git a/ui/tests/helpers/secret-engine/secret-engine-helpers.js b/ui/tests/helpers/secret-engine/secret-engine-helpers.js index 4c3c3ee115de..2a9c88601c4d 100644 --- a/ui/tests/helpers/secret-engine/secret-engine-helpers.js +++ b/ui/tests/helpers/secret-engine/secret-engine-helpers.js @@ -182,7 +182,7 @@ export const expectedValueOfConfigKeys = (type, string) => { export const fillInAwsConfig = async (situation = 'withAccess') => { if (situation === 'withAccess') { await fillIn(GENERAL.inputByAttr('accessKey'), 'foo'); - await fillIn(GENERAL.maskedInput('secretKey'), 'bar'); + await fillIn(GENERAL.inputByAttr('secretKey'), 'bar'); } if (situation === 'withAccessOptions') { await click(GENERAL.toggleGroup('Root config options')); diff --git a/ui/tests/helpers/sync/sync-selectors.js b/ui/tests/helpers/sync/sync-selectors.js index 1a5758590af3..61f4ee7616cf 100644 --- a/ui/tests/helpers/sync/sync-selectors.js +++ b/ui/tests/helpers/sync/sync-selectors.js @@ -99,11 +99,6 @@ export const PAGE = { case 'customTags': await fillIn('[data-test-kv-key="0"]', 'foo'); return fillIn('[data-test-kv-value="0"]', value); - case 'accessKeyId': - case 'secretAccessKey': - case 'clientSecret': - case 'accessToken': - return fillIn(GENERAL.maskedInput(attr), value); case 'deploymentEnvironments': await click('[data-test-input="deploymentEnvironments"] input#development'); await click('[data-test-input="deploymentEnvironments"] input#preview'); diff --git a/ui/tests/integration/components/masked-input-test.js b/ui/tests/integration/components/masked-input-test.js index ce56f3f6586d..bd511823ac60 100644 --- a/ui/tests/integration/components/masked-input-test.js +++ b/ui/tests/integration/components/masked-input-test.js @@ -6,13 +6,16 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { render, focus, triggerKeyEvent, typeIn, fillIn, click } from '@ember/test-helpers'; -import { create } from 'ember-cli-page-object'; import hbs from 'htmlbars-inline-precompile'; import sinon from 'sinon'; -import maskedInput from 'vault/tests/pages/components/masked-input'; - -const component = create(maskedInput); +const SELECTORS = { + copyBtn: '[data-test-copy-button]', + downloadBtn: '[data-test-download-button]', + toggle: '[data-test-button="toggle-masked"]', + downloadIcon: '[data-test-download-icon]', + stringify: '[data-test-stringify-toggle]', +}; module('Integration | Component | masked input', function (hooks) { setupRenderingTest(hooks); @@ -26,14 +29,14 @@ module('Integration | Component | masked input', function (hooks) { test('it renders', async function (assert) { await render(hbs``); assert.dom('[data-test-masked-input]').exists('shows masked input'); - assert.ok(component.textareaIsPresent); - assert.dom('[data-test-textarea]').hasClass('masked-font', 'it renders an input with obscure font'); - assert.notOk(component.copyButtonIsPresent, 'does not render copy button by default'); - assert.notOk(component.downloadButtonIsPresent, 'does not render download button by default'); + assert.dom('textarea').exists(); + assert.dom('textarea').hasClass('masked-font', 'it renders an input with obscure font'); + assert.dom(SELECTORS.copyBtn).doesNotExist('does not render copy button by default'); + assert.dom('[data-test-download-button]').doesNotExist('does not render download button by default'); - await component.toggleMasked(); + await click(SELECTORS.toggle); assert.dom('.masked-value').doesNotHaveClass('masked-font', 'it unmasks when show button is clicked'); - await component.toggleMasked(); + await click(SELECTORS.toggle); assert.dom('.masked-value').hasClass('masked-font', 'it remasks text when button is clicked'); }); @@ -42,21 +45,21 @@ module('Integration | Component | masked input', function (hooks) { await render(hbs``); assert.dom('.masked-value').hasClass('masked-font', 'value has obscured font'); - assert.notOk(component.textareaIsPresent, 'it does not render a textarea when displayOnly is true'); + assert.dom('textarea').doesNotExist('it does not render a textarea when displayOnly is true'); }); test('it renders a copy button when allowCopy is true', async function (assert) { this.set('value', { some: 'object' }); await render(hbs``); - assert.ok(component.copyButtonIsPresent); + assert.dom(SELECTORS.copyBtn).exists(); }); test('it renders a download button when allowDownload is true', async function (assert) { await render(hbs` `); - assert.ok(component.downloadIconIsPresent); + assert.dom(SELECTORS.downloadIcon).exists(); - await click('[data-test-download-icon]'); - assert.ok(component.downloadButtonIsPresent, 'clicking download icon opens modal with download button'); + await click(SELECTORS.downloadIcon); + assert.dom(SELECTORS.downloadBtn).exists('clicking download icon opens modal with download button'); }); test('it shortens all outputs when displayOnly and masked', async function (assert) { @@ -65,7 +68,7 @@ module('Integration | Component | masked input', function (hooks) { const maskedValue = document.querySelector('.masked-value').innerText; assert.strictEqual(maskedValue.length, 11); - await component.toggleMasked(); + await click(SELECTORS.toggle); const unMaskedValue = document.querySelector('.masked-value').innerText; assert.strictEqual(unMaskedValue.length, this.value.length); }); @@ -83,7 +86,7 @@ module('Integration | Component | masked input', function (hooks) { this.set('value', 'before'); this.set('onChange', changeSpy); await render(hbs``); - await fillIn('[data-test-textarea]', 'after'); + await fillIn('textarea', 'after'); assert.true(changeSpy.calledWith('after')); }); @@ -92,7 +95,7 @@ module('Integration | Component | masked input', function (hooks) { this.set('value', ''); this.set('onKeyUp', keyupSpy); await render(hbs``); - await typeIn('[data-test-textarea]', 'baz'); + await typeIn('textarea', 'baz'); assert.true(keyupSpy.calledThrice, 'calls for each letter of typing'); assert.true(keyupSpy.firstCall.calledWithExactly('foo', 'b')); assert.true(keyupSpy.secondCall.calledWithExactly('foo', 'ba')); @@ -102,8 +105,8 @@ module('Integration | Component | masked input', function (hooks) { test('it does not remove value on tab', async function (assert) { this.set('value', 'hello'); await render(hbs``); - await triggerKeyEvent('[data-test-textarea]', 'keydown', 9); - await component.toggleMasked(); + await triggerKeyEvent('textarea', 'keydown', 9); + await click(SELECTORS.toggle); const unMaskedValue = document.querySelector('.masked-value').value; assert.strictEqual(unMaskedValue, this.value); }); @@ -119,11 +122,11 @@ module('Integration | Component | masked input', function (hooks) { /> `); assert.dom('[data-test-masked-input]').exists('shows masked input'); - assert.ok(component.copyButtonIsPresent); - assert.ok(component.downloadIconIsPresent); - assert.dom('[data-test-button="toggle-masked"]').exists('shows toggle mask button'); + assert.dom(SELECTORS.copyBtn).exists(); + assert.dom(SELECTORS.downloadIcon).exists(); + assert.dom(SELECTORS.toggle).exists('shows toggle mask button'); - await component.toggleMasked(); + await click(SELECTORS.toggle); assert.dom('.masked-value').doesNotHaveClass('masked-font', 'it unmasks when show button is clicked'); assert .dom('[data-test-icon="minus"]') @@ -154,12 +157,12 @@ module('Integration | Component | masked input', function (hooks) { /> `); - await click('[data-test-download-icon]'); - assert.dom('[data-test-stringify-toggle]').isNotChecked('Stringify toggle off as default'); - await click('[data-test-download-button]'); + await click(SELECTORS.downloadIcon); + assert.dom(SELECTORS.stringify).isNotChecked('Stringify toggle off as default'); + await click(SELECTORS.downloadBtn); - await click('[data-test-download-icon]'); - await click('[data-test-stringify-toggle]'); - await click('[data-test-download-button]'); + await click(SELECTORS.downloadIcon); + await click(SELECTORS.stringify); + await click(SELECTORS.downloadBtn); }); }); diff --git a/ui/tests/integration/components/page/userpass-reset-password-test.js b/ui/tests/integration/components/page/userpass-reset-password-test.js index c995e10f7f1d..674a509c26ba 100644 --- a/ui/tests/integration/components/page/userpass-reset-password-test.js +++ b/ui/tests/integration/components/page/userpass-reset-password-test.js @@ -14,7 +14,7 @@ const S = { infoBanner: '[data-test-current-user-banner]', save: '[data-test-reset-password-save]', error: '[data-test-reset-password-error]', - input: '[data-test-textarea]', + input: '[data-test-input="reset-password"]', }; module('Integration | Component | page/userpass-reset-password', function (hooks) { setupRenderingTest(hooks); diff --git a/ui/tests/integration/components/secret-engine/configure-aws-test.js b/ui/tests/integration/components/secret-engine/configure-aws-test.js index deea1694ef1b..7d805d9f4c4b 100644 --- a/ui/tests/integration/components/secret-engine/configure-aws-test.js +++ b/ui/tests/integration/components/secret-engine/configure-aws-test.js @@ -69,11 +69,7 @@ module('Integration | Component | SecretEngine/ConfigureAws', function (hooks) { // check all the form fields are present await click(GENERAL.toggleGroup('Root config options')); for (const key of expectedConfigKeys('aws-root-create')) { - if (key === 'secretKey') { - assert.dom(GENERAL.maskedInput(key)).exists(`${key} shows for root section.`); - } else { - assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for root section.`); - } + assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for root section.`); } for (const key of expectedConfigKeys('aws-lease')) { assert.dom(`[data-test-ttl-form-label="${key}"]`).exists(`${key} shows for Lease section.`); @@ -94,11 +90,7 @@ module('Integration | Component | SecretEngine/ConfigureAws', function (hooks) { } // check iam fields do not show for (const key of expectedConfigKeys('aws-root-create-iam')) { - if (key === 'secretKey') { - assert.dom(GENERAL.maskedInput(key)).doesNotExist(`${key} does not show when wif is selected.`); - } else { - assert.dom(GENERAL.inputByAttr(key)).doesNotExist(`${key} does not show when wif is selected.`); - } + assert.dom(GENERAL.inputByAttr(key)).doesNotExist(`${key} does not show when wif is selected.`); } }); @@ -113,7 +105,7 @@ module('Integration | Component | SecretEngine/ConfigureAws', function (hooks) { .dom(GENERAL.inputByAttr('accessKey')) .hasValue('', 'accessKey is cleared after toggling accessType'); assert - .dom(GENERAL.maskedInput('secretKey')) + .dom(GENERAL.inputByAttr('secretKey')) .hasValue('', 'secretKey is cleared after toggling accessType'); await click(SES.aws.accessType('wif')); @@ -435,11 +427,7 @@ module('Integration | Component | SecretEngine/ConfigureAws', function (hooks) { // check all the form fields are present await click(GENERAL.toggleGroup('Root config options')); for (const key of expectedConfigKeys('aws-root-create')) { - if (key === 'secretKey') { - assert.dom(GENERAL.maskedInput(key)).exists(`${key} shows for root section.`); - } else { - assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for root section.`); - } + assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for root section.`); } for (const key of expectedConfigKeys('aws-lease')) { assert.dom(`[data-test-ttl-form-label="${key}"]`).exists(`${key} shows for Lease section.`); @@ -559,7 +547,7 @@ module('Integration | Component | SecretEngine/ConfigureAws', function (hooks) { await click(GENERAL.enableField('secretKey')); await click('[data-test-button="toggle-masked"]'); - await fillIn(GENERAL.maskedInput('secretKey'), 'new-secret'); + await fillIn(GENERAL.inputByAttr('secretKey'), 'new-secret'); await click(GENERAL.saveButton); }); }); diff --git a/ui/tests/integration/components/secret-engine/configure-ssh-test.js b/ui/tests/integration/components/secret-engine/configure-ssh-test.js index feeb579fa6e4..ae771412110a 100644 --- a/ui/tests/integration/components/secret-engine/configure-ssh-test.js +++ b/ui/tests/integration/components/secret-engine/configure-ssh-test.js @@ -33,7 +33,7 @@ module('Integration | Component | SecretEngine/configure-ssh', function (hooks) @id={{this.id}} /> `); - assert.dom(GENERAL.maskedInput('privateKey')).hasNoText('Private key is empty and reset'); + assert.dom(GENERAL.inputByAttr('privateKey')).hasNoText('Private key is empty and reset'); assert.dom(GENERAL.inputByAttr('publicKey')).hasNoText('Public key is empty and reset'); assert .dom(GENERAL.inputByAttr('generateSigningKey')) diff --git a/ui/tests/integration/components/sync/secrets/page/destinations/create-and-edit-test.js b/ui/tests/integration/components/sync/secrets/page/destinations/create-and-edit-test.js index 302e7faaea0c..3f4a32e5e47d 100644 --- a/ui/tests/integration/components/sync/secrets/page/destinations/create-and-edit-test.js +++ b/ui/tests/integration/components/sync/secrets/page/destinations/create-and-edit-test.js @@ -183,9 +183,9 @@ module('Integration | Component | sync | Secrets::Page::Destinations::CreateAndE await this.renderFormComponent(); await click(PAGE.enableField('accessKeyId')); - await click(PAGE.maskedInput('accessKeyId')); // click on input but do not change value + await click(PAGE.inputByAttr('accessKeyId')); // click on input but do not change value await click(PAGE.enableField('secretAccessKey')); - await fillIn(PAGE.maskedInput('secretAccessKey'), 'new-secret'); + await fillIn(PAGE.inputByAttr('secretAccessKey'), 'new-secret'); await click(PAGE.saveButton); }); @@ -277,10 +277,10 @@ module('Integration | Component | sync | Secrets::Page::Destinations::CreateAndE // iterate over the form fields and filter for those that are obfuscated // fill those in and assert that they are masked filteredObfuscatedFields.forEach(async (field) => { - await fillIn(PAGE.maskedInput(field.name), 'blah'); + await fillIn(PAGE.inputByAttr(field.name), 'blah'); assert - .dom(PAGE.maskedInput(field.name)) + .dom(PAGE.inputByAttr(field.name)) .hasClass('masked-font', `it renders ${field.name} for ${destination} with masked font`); assert .dom(PAGE.form.enableInput(field.name)) diff --git a/ui/tests/pages/components/masked-input.js b/ui/tests/pages/components/masked-input.js deleted file mode 100644 index b83d2a6018f2..000000000000 --- a/ui/tests/pages/components/masked-input.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { clickable, isPresent } from 'ember-cli-page-object'; - -export default { - textareaIsPresent: isPresent('[data-test-textarea]'), - copyButtonIsPresent: isPresent('[data-test-copy-button]'), - downloadIconIsPresent: isPresent('[data-test-download-icon]'), - downloadButtonIsPresent: isPresent('[data-test-download-button]'), - toggleMasked: clickable('[data-test-button="toggle-masked"]'), - copyValue: clickable('[data-test-copy-button]'), -}; diff --git a/ui/yarn.lock b/ui/yarn.lock index 960417b5dc62..2cd6df130429 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -4778,9 +4778,9 @@ __metadata: languageName: node linkType: hard -"body-parser@npm:1.20.2, body-parser@npm:^1.17.0, body-parser@npm:^1.19.0, body-parser@npm:^1.19.1": - version: 1.20.2 - resolution: "body-parser@npm:1.20.2" +"body-parser@npm:^1.20.3": + version: 1.20.3 + resolution: "body-parser@npm:1.20.3" dependencies: bytes: 3.1.2 content-type: ~1.0.5 @@ -4790,11 +4790,11 @@ __metadata: http-errors: 2.0.0 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.11.0 + qs: 6.13.0 raw-body: 2.5.2 type-is: ~1.6.18 unpipe: 1.0.0 - checksum: 14d37ec638ab5c93f6099ecaed7f28f890d222c650c69306872e00b9efa081ff6c596cd9afb9930656aae4d6c4e1c17537bea12bb73c87a217cb3cfea8896737 + checksum: 1a35c59a6be8d852b00946330141c4f142c6af0f970faa87f10ad74f1ee7118078056706a05ae3093c54dabca9cd3770fa62a170a85801da1a4324f04381167d languageName: node linkType: hard @@ -7549,9 +7549,9 @@ __metadata: languageName: node linkType: hard -"ember-a11y-testing@npm:^6.1.1": - version: 6.1.1 - resolution: "ember-a11y-testing@npm:6.1.1" +"ember-a11y-testing@npm:^7.0.1": + version: 7.0.1 + resolution: "ember-a11y-testing@npm:7.0.1" dependencies: "@ember/test-waiters": ^2.4.3 || ^3.0.0 "@scalvert/ember-setup-middleware-reporter": ^0.1.1 @@ -7563,15 +7563,15 @@ __metadata: ember-cli-typescript: ^4.2.1 ember-cli-version-checker: ^5.1.2 ember-destroyable-polyfill: ^2.0.1 - fs-extra: ^10.0.0 + fs-extra: ^11.2.0 validate-peer-dependencies: ^2.0.0 peerDependencies: - "@ember/test-helpers": ^3.0.3 + "@ember/test-helpers": ^3.0.3 || ^4.0.2 qunit: ">= 2" peerDependenciesMeta: qunit: optional: true - checksum: cbb69a7e043adb1eee73d8f46c11a9d7c393e30069ff4b0c20e3fb1d76accf460968d3d9876189a0cd1d95cfcaecc8731343cbd072171464ea35fa957cbb5ccc + checksum: d546eecd628c34161b435a7fe877a5c5e15b98d2635d4b5215510832d687e41a3bdcfdd0e56ff9dd54f574d0a679431a45b29004482a4f9dbbac1c22f715aba0 languageName: node linkType: hard @@ -15753,6 +15753,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:6.13.0": + version: 6.13.0 + resolution: "qs@npm:6.13.0" + dependencies: + side-channel: ^1.0.6 + checksum: e9404dc0fc2849245107108ce9ec2766cde3be1b271de0bf1021d049dc5b98d1a2901e67b431ac5509f865420a7ed80b7acb3980099fe1c118a1c5d2e1432ad8 + languageName: node + linkType: hard + "qs@npm:^6.4.0": version: 6.12.3 resolution: "qs@npm:6.12.3" @@ -19053,7 +19062,7 @@ __metadata: deepmerge: ^4.0.0 doctoc: ^2.2.0 dompurify: ^3.0.2 - ember-a11y-testing: ^6.1.1 + ember-a11y-testing: ^7.0.1 ember-auto-import: ^2.7.2 ember-basic-dropdown: ^8.0.4 ember-cli: ~5.4.2 diff --git a/vault/external_tests/raftha/raft_ha_test.go b/vault/external_tests/raftha/raft_ha_test.go index 3f8c57d53533..bbeb78eb3923 100644 --- a/vault/external_tests/raftha/raft_ha_test.go +++ b/vault/external_tests/raftha/raft_ha_test.go @@ -14,6 +14,7 @@ import ( vaulthttp "github.com/hashicorp/vault/http" "github.com/hashicorp/vault/sdk/helper/logging" "github.com/hashicorp/vault/vault" + "github.com/stretchr/testify/require" ) func TestRaft_HA_NewCluster(t *testing.T) { @@ -54,6 +55,20 @@ func TestRaft_HA_NewCluster(t *testing.T) { }) } +// TestRaftHA_Recover_Cluster test that we can recover data and re-boostrap a cluster +// that was created with raft HA enabled but is not using raft as the storage backend. +func TestRaftHA_Recover_Cluster(t *testing.T) { + logger := logging.NewVaultLogger(hclog.Debug).Named(t.Name()) + t.Run("file", func(t *testing.T) { + physBundle := teststorage.MakeFileBackend(t, logger) + testRaftHARecoverCluster(t, physBundle, logger) + }) + t.Run("inmem", func(t *testing.T) { + physBundle := teststorage.MakeInmemBackend(t, logger) + testRaftHARecoverCluster(t, physBundle, logger) + }) +} + func testRaftHANewCluster(t *testing.T, bundler teststorage.PhysicalBackendBundler, addClientCerts bool) { var conf vault.CoreConfig opts := vault.TestClusterOptions{HandlerFunc: vaulthttp.Handler} @@ -117,6 +132,95 @@ func testRaftHANewCluster(t *testing.T, bundler teststorage.PhysicalBackendBundl } } +// testRaftHARecoverCluster : in this test, we're going to create a raft HA cluster and store a test secret in a KVv2 +// We're going to simulate an outage and destroy the cluster but we'll keep the storage backend. +// We'll recreate a new cluster with the same storage backend and ensure that we can recover using +// sys/storage/raft/bootstrap. We'll check that the new cluster +// is functional and no data was lost: we can get the test secret from the KVv2. +func testRaftHARecoverCluster(t *testing.T, physBundle *vault.PhysicalBackendBundle, logger hclog.Logger) { + opts := vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + // We're not testing the HA, only that it can be recovered. No need for multiple cores. + NumCores: 1, + } + + haStorage, haCleanup := teststorage.MakeReusableRaftHAStorage(t, logger, opts.NumCores, physBundle) + defer haCleanup() + haStorage.Setup(nil, &opts) + cluster := vault.NewTestCluster(t, nil, &opts) + + var ( + clusterBarrierKeys [][]byte + clusterRootToken string + ) + clusterBarrierKeys = cluster.BarrierKeys + clusterRootToken = cluster.RootToken + leaderCore := cluster.Cores[0] + testhelpers.EnsureCoreUnsealed(t, cluster, leaderCore) + + leaderClient := cluster.Cores[0].Client + leaderClient.SetToken(clusterRootToken) + + // Mount a KVv2 backend to store a test data + err := leaderClient.Sys().Mount("kv", &api.MountInput{ + Type: "kv-v2", + }) + require.NoError(t, err) + + kvData := map[string]interface{}{ + "data": map[string]interface{}{ + "kittens": "awesome", + }, + } + + // Store the test data in the KVv2 backend + _, err = leaderClient.Logical().Write("kv/data/test_known_data", kvData) + require.NoError(t, err) + + // We delete the current cluster. We keep the storage backend so we can recover the cluster + cluster.Cleanup() + + // We now have a raft HA cluster with a KVv2 backend enabled and a test data. + // We're now going to delete the cluster and create a new raft HA cluster with the same backend storage + // and ensure we can recover to a working vault cluster and don't lose the data from the backend storage. + + opts = vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + // We're not testing the HA, only that it can be recovered. No need for multiple cores. + NumCores: 1, + // It's already initialized as we keep the same storage backend. + SkipInit: true, + } + haStorage, haCleanup = teststorage.MakeReusableRaftHAStorage(t, logger, opts.NumCores, physBundle) + defer haCleanup() + haStorage.Setup(nil, &opts) + clusterRestored := vault.NewTestCluster(t, nil, &opts) + + clusterRestored.BarrierKeys = clusterBarrierKeys + clusterRestored.RootToken = clusterRootToken + leaderCoreRestored := clusterRestored.Cores[0] + + testhelpers.EnsureCoresUnsealed(t, clusterRestored) + + leaderClientRestored := clusterRestored.Cores[0].Client + + // We now reset the TLS keyring and bootstrap the cluster again. + _, err = leaderClientRestored.Logical().Write("sys/storage/raft/bootstrap", nil) + require.NoError(t, err) + + vault.TestWaitActive(t, leaderCoreRestored.Core) + // Core should be active and cluster in a working state. We should be able to + // read the data from the KVv2 backend. + leaderClientRestored.SetToken(clusterRootToken) + secretRaw, err := leaderClientRestored.Logical().Read("kv/data/test_known_data") + require.NoError(t, err) + + data := secretRaw.Data["data"] + dataAsMap := data.(map[string]interface{}) + require.NotNil(t, dataAsMap) + require.Equal(t, "awesome", dataAsMap["kittens"]) +} + func TestRaft_HA_ExistingCluster(t *testing.T) { t.Parallel() conf := vault.CoreConfig{ diff --git a/vault/init.go b/vault/init.go index 28ff05743736..4e12261e0041 100644 --- a/vault/init.go +++ b/vault/init.go @@ -319,32 +319,6 @@ func (c *Core) Initialize(ctx context.Context, initParams *InitParams) (*InitRes SecretShares: [][]byte{}, } - // If we are storing shares, pop them out of the returned results and push - // them through the seal - switch c.seal.StoredKeysSupported() { - case seal.StoredKeysSupportedShamirRoot: - keysToStore := [][]byte{barrierKey} - if err := c.seal.GetAccess().SetShamirSealKey(sealKey); err != nil { - c.logger.Error("failed to set seal key", "error", err) - return nil, fmt.Errorf("failed to set seal key: %w", err) - } - if err := c.seal.SetStoredKeys(ctx, keysToStore); err != nil { - c.logger.Error("failed to store keys", "error", err) - return nil, fmt.Errorf("failed to store keys: %w", err) - } - results.SecretShares = sealKeyShares - case seal.StoredKeysSupportedGeneric: - keysToStore := [][]byte{barrierKey} - if err := c.seal.SetStoredKeys(ctx, keysToStore); err != nil { - c.logger.Error("failed to store keys", "error", err) - return nil, fmt.Errorf("failed to store keys: %w", err) - } - default: - // We don't support initializing an old-style Shamir seal anymore, so - // this case is only reachable by tests. - results.SecretShares = barrierKeyShares - } - // Perform initial setup if err := c.setupCluster(ctx); err != nil { c.logger.Error("cluster setup failed during init", "error", err) @@ -356,6 +330,12 @@ func (c *Core) Initialize(ctx context.Context, initParams *InitParams) (*InitRes initPTCleanup() } + // Save in a variable whether stored keys are supported before calling postUnsea(), as postUnseal() + // clears the barrier config. For a defaultSeal with a "legacy seal" (i.e. barrier config has StoredShares == 0), + // this will cause StoredKeysSupported() to go from StoredKeysNotSupported to StoredKeysSupportedShamirRoot. + // This would be a problem below when we determine whether to call SetStoredKeys. + storedKeysSupported := c.seal.StoredKeysSupported() + activeCtx, ctxCancel := context.WithCancel(namespace.RootContext(nil)) if err := c.postUnseal(activeCtx, ctxCancel, standardUnsealStrategy{}); err != nil { c.logger.Error("post-unseal setup failed during init", "error", err) @@ -413,6 +393,32 @@ func (c *Core) Initialize(ctx context.Context, initParams *InitParams) (*InitRes } } + // If we are storing shares, pop them out of the returned results and push + // them through the seal + switch storedKeysSupported { + case seal.StoredKeysSupportedShamirRoot: + keysToStore := [][]byte{barrierKey} + if err := c.seal.GetAccess().SetShamirSealKey(sealKey); err != nil { + c.logger.Error("failed to set seal key", "error", err) + return nil, fmt.Errorf("failed to set seal key: %w", err) + } + if err := c.seal.SetStoredKeys(ctx, keysToStore); err != nil { + c.logger.Error("failed to store keys", "error", err) + return nil, fmt.Errorf("failed to store keys: %w", err) + } + results.SecretShares = sealKeyShares + case seal.StoredKeysSupportedGeneric: + keysToStore := [][]byte{barrierKey} + if err := c.seal.SetStoredKeys(ctx, keysToStore); err != nil { + c.logger.Error("failed to store keys", "error", err) + return nil, fmt.Errorf("failed to store keys: %w", err) + } + default: + // We don't support initializing an old-style Shamir seal anymore, so + // this case is only reachable by tests. + results.SecretShares = barrierKeyShares + } + // Prepare to re-seal if err := c.preSeal(); err != nil { c.logger.Error("pre-seal teardown failed", "error", err) diff --git a/vault/raft.go b/vault/raft.go index 97ced8f089c6..7d34bef99c63 100644 --- a/vault/raft.go +++ b/vault/raft.go @@ -751,7 +751,12 @@ func (c *Core) raftCreateTLSKeyring(ctx context.Context) (*raft.TLSKeyring, erro } if raftTLSEntry != nil { - return nil, fmt.Errorf("TLS keyring already present") + // For Raft storage, the keyring should already be there, but + // for situations with non-Raft storage and Raft HA, we can ignore this, + // as it will need to be remade. + if _, usingRaftStorage := c.underlyingPhysical.(*raft.RaftBackend); usingRaftStorage { + return nil, fmt.Errorf("TLS keyring already present") + } } raftTLS, err := raft.GenerateTLSKey(c.secureRandomReader) diff --git a/website/content/api-docs/secret/ssh.mdx b/website/content/api-docs/secret/ssh.mdx index 5b60ef5cd293..ff1bf37655ba 100644 --- a/website/content/api-docs/secret/ssh.mdx +++ b/website/content/api-docs/secret/ssh.mdx @@ -752,8 +752,9 @@ parameters of the issued certificate can be further customized in this API call. set. - `valid_principals` `(string: "")` – Specifies valid principals, either - usernames or hostnames, that the certificate should be signed for. Required - unless the role has specified allow_empty_principals. + usernames or hostnames, that the certificate should be signed for. Required + unless the role has specified allow_empty_principals or a value has been set for + either the default_user or default_user_template role parameters. - `cert_type` `(string: "user")` – Specifies the type of certificate to be created; either "user" or "host". @@ -936,4 +937,4 @@ $ curl \ "warnings": null, "auth": null } -``` \ No newline at end of file +``` diff --git a/website/content/api-docs/system/internal-counters.mdx b/website/content/api-docs/system/internal-counters.mdx index 1fb93b7a0ddd..25e6e7dfadc6 100644 --- a/website/content/api-docs/system/internal-counters.mdx +++ b/website/content/api-docs/system/internal-counters.mdx @@ -1123,6 +1123,13 @@ $ curl \ ### Sample JSON response +The entity alias names for userpass in the sample response records below are user-provided. They are +system-provided for AWS and Kubernetes based on how the auth backend has been configured. In the case of +AWS, an IAM role ID is used but this can be configured via the [iam_alias](/vault/api-docs/auth/aws#iam_alias) +or [ec2_alias](/vault/api-docs/auth/aws#ec2_alias) configuration parameters. In the case of Kubernetes, the entity +alias name has been populated with the service account ID though this can be configured via the +[alias_name_source](/vault/api-docs/auth/kubernetes#alias_name_source) configuration parameter. + ~> **NOTE**: The activity records below are pretty-printed to improve readability. The API returns JSON lines and will thus be compacted so that each record consumes a single line. @@ -1230,6 +1237,53 @@ $ curl \ "76a374a1-72fd-30ca-2455-f51dfeaa805e" ] } + +{ + "entity_name": "e91fa61e-d53e-4b0a-8fe2-ce813a064caa", + "entity_alias_name": "bee2d6ea-b873-47bc-9bc3-6f5e16e5c1b3", + "local_entity_alias": false, + "client_id": "cc7c504f-8d10-4add-9951-f6a194f188ec", + "client_type": "entity", + "namespace_id": "root", + "namespace_path": "", + "mount_accessor": "auth_kubernetes_b596406f", + "mount_type": "kubernetes", + "mount_path": "auth/kubernetes/", + "timestamp": "2024-07-10T09:33:51Z", + "policies": [ + "secret-read" + ], + "entity_metadata": {}, + "entity_alias_metadata": { + "service_account_uid": "bee2d6ea-b873-47bc-9bc3-6f5e16e5c1b3", + "service_account_name": "vault-auth", + "service_account_namespace": "default", + "service_account_secret_name": "vault-auth-token" + }, + "entity_alias_custom_metadata": {}, + "entity_group_ids": [] +} + +{ + "entity_name": "55ee5905-0314-485d-85ad-c29dc987a054", + "entity_alias_name": "admin", + "local_entity_alias": false, + "client_id": "cc7c504f-8d10-4add-9951-f6a194f188ec", + "client_type": "entity", + "namespace_id": "root", + "namespace_path": "", + "mount_accessor": "auth_aws_c223ff01", + "mount_type": "aws", + "mount_path": "auth/aws/", + "timestamp": "2024-07-10T09:33:51Z", + "policies": [ + "secret-read" + ], + "entity_metadata": {}, + "entity_alias_metadata": {}, + "entity_alias_custom_metadata": {}, + "entity_group_ids": [] +} ``` ### Sample CSV response diff --git a/website/content/docs/agent-and-proxy/autoauth/index.mdx b/website/content/docs/agent-and-proxy/autoauth/index.mdx index 3bee8a43df31..442f140176a7 100644 --- a/website/content/docs/agent-and-proxy/autoauth/index.mdx +++ b/website/content/docs/agent-and-proxy/autoauth/index.mdx @@ -105,6 +105,10 @@ The top level `auto_auth` block has two configuration entries: - `sinks` `(array of objects: optional)` - Configuration for the sinks +- `enable_reauth_on_new_credentials` `(bool: false)` - If enabled, Auto-auth will + handle new credential events from supported auth methods (AliCloud/AWS/Cert/JWT/LDAP/OCI) + and re-authenticate with the new credential. + ### Configuration (Method) ~> Auto-auth does not support using tokens with a limited number of uses. Auto-auth diff --git a/website/content/docs/agent-and-proxy/autoauth/methods/cert.mdx b/website/content/docs/agent-and-proxy/autoauth/methods/cert.mdx index 8a04bac8b5ff..41740cd544aa 100644 --- a/website/content/docs/agent-and-proxy/autoauth/methods/cert.mdx +++ b/website/content/docs/agent-and-proxy/autoauth/methods/cert.mdx @@ -31,5 +31,14 @@ config stanza, Agent and Proxy will fall back to using TLS settings from their r - `client_key` `(string: optional)` - Path on the local disk to a single PEM-encoded private key matching the client certificate from client_cert. -- `reload` `(bool: optional, default: false)` - If true, causes the local x509 key-pair to be reloaded from disk on each authentication attempt. - This is useful in situations where client certificates are short-lived and automatically renewed. +- `reload` `(bool: optional, default: false)` - If true, causes the local x509 + key-pair to be reloaded from disk on each authentication attempt. This is useful + in situations where client certificates are short-lived and automatically renewed. + Note that `enable_reauth_on_new_credentials` for auto-auth will need to be additionally + enabled for immediate re-auth on a new certificate. + See [Auto-Auth Configuration](/vault/docs/agent-and-proxy/autoauth#configuration). + +- `reload_period` `(duration: "1m", optional)` - The duration after which auto-auth + will check if there are any changes for the files that are configured through + `ca_cert`/`client_cert`/`client_key`. Defaults to `1m`. + Uses [duration format strings](/vault/docs/concepts/duration-format).