From dc343f35662be3f937002ae7acfad07ce8a85762 Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Wed, 27 Nov 2024 18:03:18 -0600 Subject: [PATCH 01/45] add `read` subkeys to kv patch docs (#29028) * add read subkey to docs * TW edits * fix copy/paste error * correct nil --> null for API results * make example data match between CLI and API (1 of 2) * make example data match between CLI and API (2 of 2) --------- Co-authored-by: Sarah Chavis <62406755+schavis@users.noreply.github.com> --- .../secrets/kv/kv-v2/cookbook/patch-data.mdx | 14 ++- .../secrets/kv/kv-v2/cookbook/read-subkey.mdx | 117 ++++++++++++++++++ website/data/docs-nav-data.json | 4 + 3 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 website/content/docs/secrets/kv/kv-v2/cookbook/read-subkey.mdx diff --git a/website/content/docs/secrets/kv/kv-v2/cookbook/patch-data.mdx b/website/content/docs/secrets/kv/kv-v2/cookbook/patch-data.mdx index 733453a5ca3e..fdceefc632eb 100644 --- a/website/content/docs/secrets/kv/kv-v2/cookbook/patch-data.mdx +++ b/website/content/docs/secrets/kv/kv-v2/cookbook/patch-data.mdx @@ -15,8 +15,10 @@ an existing data path in the `kv` v2 plugin. - You have [set up a `kv` v2 plugin](/vault/docs/secrets/kv/kv-v2/setup). - Your authentication token has appropriate permissions for the `kv` v2 plugin: - **`patch`** permission to make direct updates with `PATCH` actions. - - **`create`**+**`update`** permission to make indirect updates by combining - `GET` and `POST` actions. + - **`create`**+**`update`** permission if you want to make indirect + updates with the Vault CLI by combining `GET` and `POST` actions. +- You know the keys or [subkeys](/vault/docs/secrets/kv/kv-v2/cookbook/read-subkey) + you want to patch. @@ -25,7 +27,7 @@ an existing data path in the `kv` v2 plugin. -Use the [`vault kv patch`](/vault/docs/command/kv/patch) command and set the +Use the [`vault kv patch`](/vault/docs/commands/kv/patch) command and set the `-cas` flag to the expected data version to perform a check-and-set operation before applying the patch: @@ -43,7 +45,11 @@ For example: ```shell-session -$ vault kv patch -cas 2 -mount shared dev/square-api prod=5678 +$ vault kv patch \ + -cas 2 \ + -mount shared \ + dev/square-api \ + prod=5678 ======= Secret Path ======= shared/data/dev/square-api diff --git a/website/content/docs/secrets/kv/kv-v2/cookbook/read-subkey.mdx b/website/content/docs/secrets/kv/kv-v2/cookbook/read-subkey.mdx new file mode 100644 index 000000000000..9820bf7702be --- /dev/null +++ b/website/content/docs/secrets/kv/kv-v2/cookbook/read-subkey.mdx @@ -0,0 +1,117 @@ +--- +layout: docs +page_title: Read subkeys +description: >- + Read the available subkeys on a given path from the kv v2 plugin +--- + +# Read subkeys for a key/value data path + +Read the available subkeys on an existing data path in the `kv` v2 plugin. + + + +- You have [set up a `kv` v2 plugin](/vault/docs/secrets/kv/kv-v2/setup). +- Your authentication token has `read` permissions for subkeys on the target + secret path. + + + + + + + +Use `vault read` with the `/subkeys` metadata path retrieve a list of available +subkeys on the given path. + +```shell-session +$ vault read /subkeys/ +``` + +Vault retrieves secrets at the given path but replaces the underlying values of +non-map keys and map keys with no underlying subkeys (leaf keys) with `nil`. + +For example: + + + +```shell-session +$ vault read shared/subkeys/dev/square-api + +Key Value +--- ----- +metadata map[created_time:2024-11-20T20:00:13.385182722Z custom_metadata: deletion_time: destroyed:false version:1] +subkeys map[prod: sandbox: smoke:] +``` + + + + + + + +@include 'gui-page-instructions/select-kv-mount.mdx' + +- Click through the path segments to select the relevant secret path. +- Note the subkeys listed on the data page. + +![Partial screenshot of the Vault GUI showing two key/value pairs at the path dev/square-api. The "prod" key is visible](/img/gui/kv/read-data.png) + + + + + +Call the [`/{plugin_mount_path}/subkeys/{secret_path}`](/vault/api-docs/secret/kv/kv-v2#read-secret-subkeys) +endpoint to fetch a list of available subkeys on the given path: + +```shell-session +$ curl \ + --request GET \ + --header "X-Vault-Token: ${VAULT_TOKEN}" \ + ${VAULT_ADDR}/v1//subkeys/ +``` + +Vault retrieves secrets at the given path but replaces the underlying values of +non-map keys and map keys with no underlying subkeys (leaf keys) with `null`. + +For example: + + + +```shell-session +$ curl \ + --request GET \ + --header "X-Vault-Token: ${VAULT_TOKEN}" \ + ${VAULT_ADDR}/v1/shared/subkeys/dev/square-api | jq + +{ + "request_id": "bfeac3c5-f4dc-37b2-8909-3b15121cfd20", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "metadata": { + "created_time": "2024-11-20T20:00:13.385182722Z", + "custom_metadata": null, + "deletion_time": "", + "destroyed": false, + "version": 11 + }, + "subkeys": { + "prod": null, + "sandbox": null, + "smoke": null + } + }, + "wrap_info": null, + "warnings": null, + "auth": null, + "mount_type": "kv" +} +``` + + + + + + diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index a460b9ca149e..2d173797820f 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -1606,6 +1606,10 @@ "title": "Read data", "path": "secrets/kv/kv-v2/cookbook/read-data" }, + { + "title": "Read subkeys", + "path": "secrets/kv/kv-v2/cookbook/read-subkey" + }, { "title": "Set max data versions", "path": "secrets/kv/kv-v2/cookbook/max-versions" From ba969bb14fd90a7d8793d95519a4e1b39a0b2900 Mon Sep 17 00:00:00 2001 From: Victor Rodriguez Date: Thu, 28 Nov 2024 16:27:17 +0100 Subject: [PATCH 02/45] Run make fmt. (#29053) --- builtin/credential/okta/path_config.go | 3 +-- builtin/logical/pki/issuing/cert_verify.go | 2 +- tools/pipeline/internal/cmd/generate_enos_dynamic_config.go | 3 +-- tools/pipeline/internal/cmd/releases_list_versions.go | 3 +-- tools/pipeline/internal/pkg/generate/enos_dynamic_config.go | 3 +-- .../pipeline/internal/pkg/generate/enos_dynamic_config_test.go | 3 +-- tools/pipeline/internal/pkg/releases/client.go | 3 +-- tools/pipeline/internal/pkg/releases/client_mock.go | 1 - tools/pipeline/internal/pkg/releases/list_versions.go | 3 +-- 9 files changed, 8 insertions(+), 16 deletions(-) diff --git a/builtin/credential/okta/path_config.go b/builtin/credential/okta/path_config.go index db3e9c54ad48..b74fdbb9b801 100644 --- a/builtin/credential/okta/path_config.go +++ b/builtin/credential/okta/path_config.go @@ -14,14 +14,13 @@ import ( "strings" "time" - gocache "github.com/patrickmn/go-cache" - oktaold "github.com/chrismalek/oktasdk-go/okta" "github.com/hashicorp/go-cleanhttp" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/helper/tokenutil" "github.com/hashicorp/vault/sdk/logical" oktanew "github.com/okta/okta-sdk-golang/v5/okta" + gocache "github.com/patrickmn/go-cache" ) const ( diff --git a/builtin/logical/pki/issuing/cert_verify.go b/builtin/logical/pki/issuing/cert_verify.go index a04b1c83b744..f17cd7de087d 100644 --- a/builtin/logical/pki/issuing/cert_verify.go +++ b/builtin/logical/pki/issuing/cert_verify.go @@ -6,13 +6,13 @@ package issuing import ( "context" "fmt" - "github.com/hashicorp/vault/sdk/logical" "os" "strconv" "time" ctx509 "github.com/google/certificate-transparency-go/x509" "github.com/hashicorp/vault/sdk/helper/certutil" + "github.com/hashicorp/vault/sdk/logical" ) // disableVerifyCertificateEnvVar is an environment variable that can be used to disable the diff --git a/tools/pipeline/internal/cmd/generate_enos_dynamic_config.go b/tools/pipeline/internal/cmd/generate_enos_dynamic_config.go index 955b71ae3780..e2e3c63f4d97 100644 --- a/tools/pipeline/internal/cmd/generate_enos_dynamic_config.go +++ b/tools/pipeline/internal/cmd/generate_enos_dynamic_config.go @@ -6,10 +6,9 @@ package cmd import ( "context" - "github.com/spf13/cobra" - "github.com/hashicorp/vault/tools/pipeline/internal/pkg/generate" "github.com/hashicorp/vault/tools/pipeline/internal/pkg/releases" + "github.com/spf13/cobra" ) // skipVersionsDefault are versions that we skip by default. This list can grow as necessary. diff --git a/tools/pipeline/internal/cmd/releases_list_versions.go b/tools/pipeline/internal/cmd/releases_list_versions.go index 693d01453897..0fa7fbca8155 100644 --- a/tools/pipeline/internal/cmd/releases_list_versions.go +++ b/tools/pipeline/internal/cmd/releases_list_versions.go @@ -8,9 +8,8 @@ import ( "encoding/json" "fmt" - "github.com/spf13/cobra" - "github.com/hashicorp/vault/tools/pipeline/internal/pkg/releases" + "github.com/spf13/cobra" ) var listReleaseVersionsReq = &releases.ListVersionsReq{ diff --git a/tools/pipeline/internal/pkg/generate/enos_dynamic_config.go b/tools/pipeline/internal/pkg/generate/enos_dynamic_config.go index e0d71ed9a2dc..ccbc1b02c186 100644 --- a/tools/pipeline/internal/pkg/generate/enos_dynamic_config.go +++ b/tools/pipeline/internal/pkg/generate/enos_dynamic_config.go @@ -13,12 +13,11 @@ import ( "slices" "github.com/Masterminds/semver" - slogctx "github.com/veqryn/slog-context" - "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/hashicorp/vault/tools/pipeline/internal/pkg/metadata" "github.com/hashicorp/vault/tools/pipeline/internal/pkg/releases" + slogctx "github.com/veqryn/slog-context" ) // EnosDynamicConfigReq is a request to generate dynamic enos configuration diff --git a/tools/pipeline/internal/pkg/generate/enos_dynamic_config_test.go b/tools/pipeline/internal/pkg/generate/enos_dynamic_config_test.go index bb395a90a4eb..03f3ee0d6a3a 100644 --- a/tools/pipeline/internal/pkg/generate/enos_dynamic_config_test.go +++ b/tools/pipeline/internal/pkg/generate/enos_dynamic_config_test.go @@ -10,9 +10,8 @@ import ( "slices" "testing" - "github.com/stretchr/testify/require" - "github.com/hashicorp/vault/tools/pipeline/internal/pkg/releases" + "github.com/stretchr/testify/require" ) var testAPIVersions = []string{ diff --git a/tools/pipeline/internal/pkg/releases/client.go b/tools/pipeline/internal/pkg/releases/client.go index 86b0474093f2..0190c633e2c8 100644 --- a/tools/pipeline/internal/pkg/releases/client.go +++ b/tools/pipeline/internal/pkg/releases/client.go @@ -11,11 +11,10 @@ import ( "time" "github.com/Masterminds/semver" - slogctx "github.com/veqryn/slog-context" - "github.com/hashicorp/releases-api/pkg/api" "github.com/hashicorp/releases-api/pkg/client" "github.com/hashicorp/releases-api/pkg/models" + slogctx "github.com/veqryn/slog-context" ) // Client is an api.releases.hashicorp.com API client. diff --git a/tools/pipeline/internal/pkg/releases/client_mock.go b/tools/pipeline/internal/pkg/releases/client_mock.go index 07537332326a..cd6c6b466721 100644 --- a/tools/pipeline/internal/pkg/releases/client_mock.go +++ b/tools/pipeline/internal/pkg/releases/client_mock.go @@ -7,7 +7,6 @@ import ( "context" "github.com/Masterminds/semver" - "github.com/hashicorp/releases-api/pkg/models" ) diff --git a/tools/pipeline/internal/pkg/releases/list_versions.go b/tools/pipeline/internal/pkg/releases/list_versions.go index bd2e9e1f0069..490e530945ac 100644 --- a/tools/pipeline/internal/pkg/releases/list_versions.go +++ b/tools/pipeline/internal/pkg/releases/list_versions.go @@ -11,9 +11,8 @@ import ( "slices" "github.com/Masterminds/semver" - slogctx "github.com/veqryn/slog-context" - "github.com/hashicorp/vault/tools/pipeline/internal/pkg/metadata" + slogctx "github.com/veqryn/slog-context" ) // ListVersionsReq is a request to list versions from the releases API. From 93ca099e3c466564b6088f1e0de03754b64158c9 Mon Sep 17 00:00:00 2001 From: Steven Clark Date: Thu, 28 Nov 2024 14:30:17 -0500 Subject: [PATCH 03/45] Update docs adding use_pss to PKI root generation api (#29023) - We missed adding this flag to the root CA generation call, but we do support it. --- website/content/api-docs/secret/pki/index.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/content/api-docs/secret/pki/index.mdx b/website/content/api-docs/secret/pki/index.mdx index 198c4eda0ab0..e76829bfb150 100644 --- a/website/content/api-docs/secret/pki/index.mdx +++ b/website/content/api-docs/secret/pki/index.mdx @@ -2186,7 +2186,9 @@ use the values set via `config/urls`. `YYYY-MM-ddTHH:MM:SSZ`. Supports the Y10K end date for IEEE 802.1AR-2018 standard devices, `9999-12-31T23:59:59Z`. -* ~> Note: Keys of type `rsa` currently only support PKCS#1 v1.5 signatures. +- `use_pss` `(bool: false)` - Specifies whether or not to use PSS signatures + over PKCS#1v1.5 signatures when a RSA-type issuer is used. Ignored for + ECDSA/Ed25519 issuers. #### Managed keys parameters From e7d01654c2e0c8f3e35ee6de3e0a3d643d1c2aae Mon Sep 17 00:00:00 2001 From: Violet Hynes Date: Thu, 28 Nov 2024 15:22:11 -0500 Subject: [PATCH 04/45] Seal and cleanup cluster before test teardown for TestRaftHA_Recover_Cluster (#29057) * Seal and cleanup cluster before test teardown for TestRaftHA_Recover_Cluster * Remove old cleanup --- vault/external_tests/raftha/raft_ha_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/vault/external_tests/raftha/raft_ha_test.go b/vault/external_tests/raftha/raft_ha_test.go index bbeb78eb3923..75b1ff9c24c0 100644 --- a/vault/external_tests/raftha/raft_ha_test.go +++ b/vault/external_tests/raftha/raft_ha_test.go @@ -177,9 +177,6 @@ func testRaftHARecoverCluster(t *testing.T, physBundle *vault.PhysicalBackendBun _, 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. @@ -219,6 +216,11 @@ func testRaftHARecoverCluster(t *testing.T, physBundle *vault.PhysicalBackendBun dataAsMap := data.(map[string]interface{}) require.NotNil(t, dataAsMap) require.Equal(t, "awesome", dataAsMap["kittens"]) + + // Ensure no writes are happening before we try to clean it up, to prevent + // issues deleting the files. + clusterRestored.EnsureCoresSealed(t) + clusterRestored.Cleanup() } func TestRaft_HA_ExistingCluster(t *testing.T) { From 9bf3d115fc756e9f30c477c6b82746db79fa105a Mon Sep 17 00:00:00 2001 From: Steven Clark Date: Fri, 29 Nov 2024 10:22:09 -0500 Subject: [PATCH 05/45] Add an option to allow cert-auth to return metadata about client cert that fails login (#29044) * Add an option to allow cert-auth to return metadata about client certs that fail login * Add cl * Update SPDX header for sdk/logical/response_test.go --- builtin/credential/cert/backend_test.go | 34 +++++++++ builtin/credential/cert/path_config.go | 10 +++ builtin/credential/cert/path_login.go | 64 +++++++++++++++- builtin/credential/cert/path_login_test.go | 88 +++++++++++++++++++++- changelog/29044.txt | 3 + sdk/logical/response.go | 9 +++ sdk/logical/response_test.go | 24 ++++++ vault/identity_store_entities.go | 11 +-- website/content/api-docs/auth/cert.mdx | 3 + 9 files changed, 232 insertions(+), 14 deletions(-) create mode 100644 changelog/29044.txt create mode 100644 sdk/logical/response_test.go diff --git a/builtin/credential/cert/backend_test.go b/builtin/credential/cert/backend_test.go index e4affa3b5296..0840362f0bdc 100644 --- a/builtin/credential/cert/backend_test.go +++ b/builtin/credential/cert/backend_test.go @@ -1317,6 +1317,8 @@ func TestBackend_ext_singleCert(t *testing.T) { testAccStepLoginWithMetadata(t, connState, "web", map[string]string{"2-1-1-1": "A UTF8String Extension"}, true), testAccStepCert(t, "web", ca, "foo", allowed{metadata_ext: "1.2.3.45"}, false), testAccStepLoginWithMetadata(t, connState, "web", map[string]string{}, true), + testAccStepSetConfig(t, config{EnableMetadataOnFailures: true}, connState), + testAccStepReadConfig(t, config{EnableMetadataOnFailures: true}, connState), }, }) } @@ -1728,6 +1730,7 @@ func testAccStepSetConfig(t *testing.T, conf config, connState tls.ConnectionSta ConnState: &connState, Data: map[string]interface{}{ "enable_identity_alias_metadata": conf.EnableIdentityAliasMetadata, + "enable_metadata_on_failures": conf.EnableMetadataOnFailures, }, } } @@ -1752,6 +1755,20 @@ func testAccStepReadConfig(t *testing.T, conf config, connState tls.ConnectionSt t.Fatalf("bad: expected enable_identity_alias_metadata to be %t, got %t", conf.EnableIdentityAliasMetadata, b) } + metaValueRaw, ok := resp.Data["enable_metadata_on_failures"] + if !ok { + t.Fatalf("enable_metadata_on_failures not found in response") + } + + metaValue, ok := metaValueRaw.(bool) + if !ok { + t.Fatalf("bad: expected enable_metadata_on_failures to be a bool") + } + + if metaValue != conf.EnableMetadataOnFailures { + t.Fatalf("bad: expected enable_metadata_on_failures to be %t, got %t", conf.EnableMetadataOnFailures, metaValue) + } + return nil }, } @@ -1936,6 +1953,23 @@ func testAccStepCert(t *testing.T, name string, cert []byte, policies string, te return testAccStepCertWithExtraParams(t, name, cert, policies, testData, expectError, nil) } +func testStepEnableMetadataFailures() logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.UpdateOperation, + Path: "config", + ErrorOk: false, + Data: map[string]interface{}{ + "enable_metadata_on_failures": true, + }, + Check: func(resp *logical.Response) error { + if resp != nil && resp.IsError() { + return fmt.Errorf("expected nil response got a response error: %v", resp) + } + return nil + }, + } +} + func testAccStepCertWithExtraParams(t *testing.T, name string, cert []byte, policies string, testData allowed, expectError bool, extraParams map[string]interface{}) logicaltest.TestStep { data := map[string]interface{}{ "certificate": string(cert), diff --git a/builtin/credential/cert/path_config.go b/builtin/credential/cert/path_config.go index 1183775f6bc4..c5c32be97d69 100644 --- a/builtin/credential/cert/path_config.go +++ b/builtin/credential/cert/path_config.go @@ -42,6 +42,11 @@ func pathConfig(b *backend) *framework.Path { Default: defaultRoleCacheSize, Description: `The size of the in memory role cache`, }, + "enable_metadata_on_failures": { + Type: framework.TypeBool, + Default: false, + Description: `If set, metadata of the client certificate will be returned on authentication failures.`, + }, }, Operations: map[logical.Operation]framework.OperationHandler{ @@ -87,6 +92,9 @@ func (b *backend) pathConfigWrite(ctx context.Context, req *logical.Request, dat } config.RoleCacheSize = cacheSize } + if enableMetadataOnFailures, ok := data.GetOk("enable_metadata_on_failures"); ok { + config.EnableMetadataOnFailures = enableMetadataOnFailures.(bool) + } if err := b.storeConfig(ctx, req.Storage, config); err != nil { return nil, err } @@ -104,6 +112,7 @@ func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, d *f "enable_identity_alias_metadata": cfg.EnableIdentityAliasMetadata, "ocsp_cache_size": cfg.OcspCacheSize, "role_cache_size": cfg.RoleCacheSize, + "enable_metadata_on_failures": cfg.EnableMetadataOnFailures, } return &logical.Response{ @@ -133,4 +142,5 @@ type config struct { EnableIdentityAliasMetadata bool `json:"enable_identity_alias_metadata"` OcspCacheSize int `json:"ocsp_cache_size"` RoleCacheSize int `json:"role_cache_size"` + EnableMetadataOnFailures bool `json:"enable_metadata_on_failures"` } diff --git a/builtin/credential/cert/path_login.go b/builtin/credential/cert/path_login.go index 53571b26185e..d770b1b9da3a 100644 --- a/builtin/credential/cert/path_login.go +++ b/builtin/credential/cert/path_login.go @@ -70,12 +70,20 @@ func (b *backend) loginPathWrapper(wrappedOp func(ctx context.Context, req *logi } func (b *backend) pathLoginResolveRole(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + config, err := b.Config(ctx, req.Storage) + if err != nil { + return nil, err + } + if b.configUpdated.Load() { + b.updatedConfig(config) + } + var matched *ParsedCert if verifyResp, resp, err := b.verifyCredentials(ctx, req, data); err != nil { return nil, err } else if resp != nil { - return resp, nil + return certAuthLoginFailureResponse(config, resp, req), nil } else { matched = verifyResp } @@ -118,7 +126,7 @@ func (b *backend) pathLogin(ctx context.Context, req *logical.Request, data *fra if verifyResp, resp, err := b.verifyCredentials(ctx, req, data); err != nil { return nil, err } else if resp != nil { - return resp, nil + return certAuthLoginFailureResponse(config, resp, req), nil } else { matched = verifyResp } @@ -181,6 +189,56 @@ func (b *backend) pathLogin(ctx context.Context, req *logical.Request, data *fra }, nil } +func certAuthLoginFailureResponse(config *config, resp *logical.Response, req *logical.Request) *logical.Response { + if !config.EnableMetadataOnFailures || !resp.IsError() { + return resp + } + var initialErrMsg string + if err := resp.Error(); err != nil { + initialErrMsg = err.Error() + } + + clientCert, exists := getClientCert(req) + if !exists { + return logical.ErrorResponse("no client certificate found\n" + initialErrMsg) + } + + // Trim these values as they can be anything from any sort of failed certificate + // and we don't want to expose audit entries to randomly large strings. + const maxChars = 100 + metadata := map[string]string{ + "common_name": trimToMaxChars(clientCert.Subject.CommonName, maxChars), + "serial_number": trimToMaxChars(clientCert.SerialNumber.String(), maxChars), + "subject_key_id": trimToMaxChars(certutil.GetHexFormatted(clientCert.SubjectKeyId, ":"), maxChars), + "authority_key_id": trimToMaxChars(certutil.GetHexFormatted(clientCert.AuthorityKeyId, ":"), maxChars), + } + + return logical.ErrorResponseWithData(metadata, initialErrMsg) +} + +func getClientCert(req *logical.Request) (*x509.Certificate, bool) { + if req == nil || req.Connection == nil || req.Connection.ConnState == nil || req.Connection.ConnState.PeerCertificates == nil { + return nil, false + } + clientCerts := req.Connection.ConnState.PeerCertificates + if len(clientCerts) == 0 { + return nil, false + } + clientCert := clientCerts[0] + if clientCert == nil || clientCert.IsCA { + return nil, false + } + return clientCert, true +} + +func trimToMaxChars(formatted string, maxSize int) string { + if len(formatted) > maxSize { + return formatted[:maxSize-3] + "..." + } + + return formatted +} + func (b *backend) pathLoginRenew(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { config, err := b.Config(ctx, req.Storage) if err != nil { @@ -195,7 +253,7 @@ func (b *backend) pathLoginRenew(ctx context.Context, req *logical.Request, d *f if verifyResp, resp, err := b.verifyCredentials(ctx, req, d); err != nil { return nil, err } else if resp != nil { - return resp, nil + return certAuthLoginFailureResponse(config, resp, req), nil } else { matched = verifyResp } diff --git a/builtin/credential/cert/path_login_test.go b/builtin/credential/cert/path_login_test.go index ad1030464f35..9db8e7e6e19d 100644 --- a/builtin/credential/cert/path_login_test.go +++ b/builtin/credential/cert/path_login_test.go @@ -24,6 +24,7 @@ import ( logicaltest "github.com/hashicorp/vault/helper/testhelpers/logical" "github.com/hashicorp/vault/sdk/helper/certutil" "github.com/hashicorp/vault/sdk/logical" + "github.com/stretchr/testify/require" "golang.org/x/crypto/ocsp" ) @@ -203,6 +204,51 @@ func testAccStepResolveRoleExpectRoleResolutionToFail(t *testing.T, connState tl t.Fatal("Error not part of response.") } + if _, dataKeyExists := resp.Data["data"]; dataKeyExists { + t.Fatal("metadata key 'data' existed in response without feature enabled") + } + + if !strings.Contains(errString, certAuthFailMsg) { + t.Fatalf("Error was not due to invalid role name. Error: %s", errString) + } + return nil + }, + Data: map[string]interface{}{ + "name": certName, + }, + } +} + +func testAccStepResolveRoleExpectRoleResolutionToFailWithData(t *testing.T, connState tls.ConnectionState, certName string) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.ResolveRoleOperation, + Path: "login", + Unauthenticated: true, + ConnState: &connState, + ErrorOk: true, + Check: func(resp *logical.Response) error { + if resp == nil && !resp.IsError() { + t.Fatalf("Response was not an error: resp:%#v", resp) + } + + errString, ok := resp.Data["error"].(string) + if !ok { + t.Fatal("Error not part of response.") + } + + dataKeysRaw, dataKeyExists := resp.Data["data"] + if !dataKeyExists { + t.Fatal("metadata key 'data' did not exist in response feature enabled") + } + dataKeys, ok := dataKeysRaw.(map[string]string) + if !ok { + t.Fatalf("the 'data' field was not a map: %T", dataKeysRaw) + } + + for _, key := range []string{"common_name", "serial_number", "authority_key_id", "subject_key_id"} { + require.Contains(t, dataKeys, key, "response metadata key %s was missing in response: %v", key, resp) + } + if !strings.Contains(errString, certAuthFailMsg) { t.Fatalf("Error was not due to invalid role name. Error: %s", errString) } @@ -420,6 +466,44 @@ func TestCert_RoleResolveOCSP(t *testing.T) { } } -func serialFromBigInt(serial *big.Int) string { - return strings.TrimSpace(certutil.GetHexFormatted(serial.Bytes(), ":")) +// TestCert_MetadataOnFailure verifies that we return the cert metadata +// in the response on failures if the configuration option is enabled. +func TestCert_MetadataOnFailure(t *testing.T) { + certTemplate := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "example.com", + }, + DNSNames: []string{"example.com"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + }, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageKeyAgreement, + SerialNumber: big.NewInt(mathrand.Int63()), + NotBefore: time.Now().Add(-30 * time.Second), + NotAfter: time.Now().Add(262980 * time.Hour), + } + + tempDir, connState, err := generateTestCertAndConnState(t, certTemplate) + if tempDir != "" { + defer os.RemoveAll(tempDir) + } + if err != nil { + t.Fatalf("error testing connection state: %v", err) + } + ca, err := ioutil.ReadFile(filepath.Join(tempDir, "ca_cert.pem")) + if err != nil { + t.Fatalf("err: %v", err) + } + + logicaltest.Test(t, logicaltest.TestCase{ + CredentialBackend: testFactory(t), + Steps: []logicaltest.TestStep{ + testStepEnableMetadataFailures(), + testAccStepCert(t, "web", ca, "foo", allowed{dns: "example.com"}, false), + testAccStepLoginWithName(t, connState, "web"), + testAccStepResolveRoleExpectRoleResolutionToFailWithData(t, connState, "notweb"), + }, + }) } diff --git a/changelog/29044.txt b/changelog/29044.txt new file mode 100644 index 000000000000..a4e54a2f7967 --- /dev/null +++ b/changelog/29044.txt @@ -0,0 +1,3 @@ +```release-note:improvement +auth/cert: Add new configuration option `enable_metadata_on_failures` to add client cert metadata on login failures to audit log and response +``` diff --git a/sdk/logical/response.go b/sdk/logical/response.go index 721618c76c17..d598c1675c3f 100644 --- a/sdk/logical/response.go +++ b/sdk/logical/response.go @@ -141,6 +141,15 @@ func ErrorResponse(text string, vargs ...interface{}) *Response { } } +// ErrorResponseWithData is used to format an error response with additional data returned +// within the "data" sub-field of the Data field. Useful to return additional information to the client +// and or appear within audited responses. +func ErrorResponseWithData(data interface{}, text string, vargs ...interface{}) *Response { + resp := ErrorResponse(text, vargs...) + resp.Data["data"] = data + return resp +} + // ListResponse is used to format a response to a list operation. func ListResponse(keys []string) *Response { resp := &Response{ diff --git a/sdk/logical/response_test.go b/sdk/logical/response_test.go new file mode 100644 index 000000000000..033c4fbb70f1 --- /dev/null +++ b/sdk/logical/response_test.go @@ -0,0 +1,24 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package logical + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestResponse_ErrorResponse validates that our helper functions produce responses +// that we consider errors. +func TestResponse_ErrorResponse(t *testing.T) { + simpleResp := ErrorResponse("a test %s", "error") + assert.True(t, simpleResp.IsError()) + + dataMap := map[string]string{ + "test1": "testing", + } + + withDataResp := ErrorResponseWithData(dataMap, "a test %s", "error") + assert.True(t, withDataResp.IsError()) +} diff --git a/vault/identity_store_entities.go b/vault/identity_store_entities.go index f179d0b5a629..5c6e091bb24a 100644 --- a/vault/identity_store_entities.go +++ b/vault/identity_store_entities.go @@ -10,7 +10,7 @@ import ( "strings" "github.com/golang/protobuf/ptypes" - memdb "github.com/hashicorp/go-memdb" + "github.com/hashicorp/go-memdb" "github.com/hashicorp/go-multierror" "github.com/hashicorp/go-secure-stdlib/strutil" "github.com/hashicorp/vault/helper/identity" @@ -286,14 +286,7 @@ func (i *IdentityStore) pathEntityMergeID() framework.OperationFunc { return logical.ErrorResponse(userErr.Error()), nil } // Alias clash error, so include additional details - resp := &logical.Response{ - Data: map[string]interface{}{ - "error": userErr.Error(), - "data": aliases, - }, - } - - return resp, nil + return logical.ErrorResponseWithData(aliases, userErr.Error()), nil } if intErr != nil { return nil, intErr diff --git a/website/content/api-docs/auth/cert.mdx b/website/content/api-docs/auth/cert.mdx index 25deca159e15..8581ff41b303 100644 --- a/website/content/api-docs/auth/cert.mdx +++ b/website/content/api-docs/auth/cert.mdx @@ -367,6 +367,9 @@ Configuration options for the method. that this cache is used for all configured certificates. - `role_cache_size` `(int: 200)` - The size of the role cache. Use `-1` to disable role caching. +- `enable_metadata_on_failures` `(boolean: false)` - If set, metadata of the client + certificate such as common name, serial, subject key id and authority key id will + be returned on authentication failures and appear in auditing records. ### Sample payload From 4b456ffcec0036d88b8eb0e43ff88808fe45b8ee Mon Sep 17 00:00:00 2001 From: Andy Bao <142330629+andybao-dd@users.noreply.github.com> Date: Fri, 29 Nov 2024 12:19:45 -0500 Subject: [PATCH 06/45] Fix OSS sealunwrapper adding extra get + put request to all storage get requests (#29050) * fix OSS sealunwrapper adding extra get + put request to all storage requests * Add changelog entry --- changelog/29050.txt | 4 ++ sdk/physical/inmem/inmem_ha.go | 7 ++++ vault/sealunwrapper.go | 70 ++++++++++++++++++---------------- vault/sealunwrapper_test.go | 53 ++++++++++++++++--------- 4 files changed, 85 insertions(+), 49 deletions(-) create mode 100644 changelog/29050.txt diff --git a/changelog/29050.txt b/changelog/29050.txt new file mode 100644 index 000000000000..96ecd1e53518 --- /dev/null +++ b/changelog/29050.txt @@ -0,0 +1,4 @@ +```release-note:bug +core: fix bug in seal unwrapper that caused high storage latency in Vault CE. For every storage read request, the +seal unwrapper was performing the read twice, and would also issue an unnecessary storage write. +``` diff --git a/sdk/physical/inmem/inmem_ha.go b/sdk/physical/inmem/inmem_ha.go index 1db26ca7461f..f604a1542def 100644 --- a/sdk/physical/inmem/inmem_ha.go +++ b/sdk/physical/inmem/inmem_ha.go @@ -83,6 +83,13 @@ func (i *InmemHABackend) HAEnabled() bool { return true } +func (i *InmemHABackend) Underlying() *InmemBackend { + if txBackend, ok := i.Backend.(*TransactionalInmemBackend); ok { + return &txBackend.InmemBackend + } + return i.Backend.(*InmemBackend) +} + // InmemLock is an in-memory Lock implementation for the HABackend type InmemLock struct { in *InmemHABackend diff --git a/vault/sealunwrapper.go b/vault/sealunwrapper.go index 27f4cd482703..79b1436c50d8 100644 --- a/vault/sealunwrapper.go +++ b/vault/sealunwrapper.go @@ -18,10 +18,9 @@ import ( // NewSealUnwrapper creates a new seal unwrapper func NewSealUnwrapper(underlying physical.Backend, logger log.Logger) physical.Backend { ret := &sealUnwrapper{ - underlying: underlying, - logger: logger, - locks: locksutil.CreateLocks(), - allowUnwraps: new(uint32), + underlying: underlying, + logger: logger, + locks: locksutil.CreateLocks(), } if underTxn, ok := underlying.(physical.Transactional); ok { @@ -43,7 +42,7 @@ type sealUnwrapper struct { underlying physical.Backend logger log.Logger locks []*locksutil.LockEntry - allowUnwraps *uint32 + allowUnwraps atomic.Bool } // transactionalSealUnwrapper is a seal unwrapper that wraps a physical that is transactional @@ -63,63 +62,70 @@ func (d *sealUnwrapper) Put(ctx context.Context, entry *physical.Entry) error { return d.underlying.Put(ctx, entry) } -// unwrap gets an entry from underlying storage and tries to unwrap it. If the entry was not wrapped, return -// value unwrappedEntry will be nil. If the entry is wrapped and encrypted, an error is returned. -func (d *sealUnwrapper) unwrap(ctx context.Context, key string) (entry, unwrappedEntry *physical.Entry, err error) { - entry, err = d.underlying.Get(ctx, key) +// unwrap gets an entry from underlying storage and tries to unwrap it. +// - If the entry is not wrapped: the entry will be returned unchanged and wasWrapped will be false +// - If the entry is wrapped and encrypted: an error is returned. +// - If the entry is wrapped but not encrypted: the entry will be unwrapped and returned. wasWrapped will be true. +func (d *sealUnwrapper) unwrap(ctx context.Context, key string) (unwrappedEntry *physical.Entry, wasWrapped bool, err error) { + entry, err := d.underlying.Get(ctx, key) if err != nil { - return nil, nil, err + return nil, false, err } if entry == nil { - return nil, nil, err + return nil, false, nil } wrappedEntryValue, unmarshaled := UnmarshalSealWrappedValueWithCanary(entry.Value) switch { case !unmarshaled: - unwrappedEntry = entry + // Entry is not wrapped + return entry, false, nil case wrappedEntryValue.isEncrypted(): - return nil, nil, fmt.Errorf("cannot decode sealwrapped storage entry %q", entry.Key) + // Entry is wrapped and encrypted + return nil, true, fmt.Errorf("cannot decode sealwrapped storage entry %q", entry.Key) default: + // Entry is wrapped and not encrypted pt, err := wrappedEntryValue.getPlaintextValue() if err != nil { - return nil, nil, err + return nil, true, err } - unwrappedEntry = &physical.Entry{ + return &physical.Entry{ Key: entry.Key, Value: pt, - } + }, true, nil } - - return entry, unwrappedEntry, nil } func (d *sealUnwrapper) Get(ctx context.Context, key string) (*physical.Entry, error) { - entry, unwrappedEntry, err := d.unwrap(ctx, key) + entry, wasWrapped, err := d.unwrap(ctx, key) switch { - case err != nil: + case err != nil: // Failed to get entry return nil, err - case entry == nil: + case entry == nil: // Entry doesn't exist return nil, nil - case atomic.LoadUint32(d.allowUnwraps) != 1: - return unwrappedEntry, nil + case !wasWrapped || !d.allowUnwraps.Load(): // Entry was not wrapped or unwrapping not allowed + return entry, nil } + // Entry was wrapped, we need to replace it with the unwrapped value + + // Grab locks because we are performing a write locksutil.LockForKey(d.locks, key).Lock() defer locksutil.LockForKey(d.locks, key).Unlock() - // At this point we need to re-read and re-check - entry, unwrappedEntry, err = d.unwrap(ctx, key) + // Read entry again in case it was changed while we were waiting for the lock + entry, wasWrapped, err = d.unwrap(ctx, key) switch { - case err != nil: + case err != nil: // Failed to get entry return nil, err - case entry == nil: + case entry == nil: // Entry doesn't exist return nil, nil - case atomic.LoadUint32(d.allowUnwraps) != 1: - return unwrappedEntry, nil + case !wasWrapped || !d.allowUnwraps.Load(): // Entry was not wrapped or unwrapping not allowed + return entry, nil } - return unwrappedEntry, d.underlying.Put(ctx, unwrappedEntry) + // Write out the unwrapped value + return entry, d.underlying.Put(ctx, entry) } func (d *sealUnwrapper) Delete(ctx context.Context, key string) error { @@ -155,12 +161,12 @@ func (d *transactionalSealUnwrapper) Transaction(ctx context.Context, txns []*ph // This should only run during preSeal which ensures that it can't be run // concurrently and that it will be run only by the active node func (d *sealUnwrapper) stopUnwraps() { - atomic.StoreUint32(d.allowUnwraps, 0) + d.allowUnwraps.Store(false) } func (d *sealUnwrapper) runUnwraps() { // Allow key unwraps on key gets. This gets set only when running on the // active node to prevent standbys from changing data underneath the // primary - atomic.StoreUint32(d.allowUnwraps, 1) + d.allowUnwraps.Store(true) } diff --git a/vault/sealunwrapper_test.go b/vault/sealunwrapper_test.go index 023ae49ded67..e4cb73de99bb 100644 --- a/vault/sealunwrapper_test.go +++ b/vault/sealunwrapper_test.go @@ -21,25 +21,29 @@ import ( func TestSealUnwrapper(t *testing.T) { logger := corehelpers.NewTestLogger(t) - // Test without transactions - phys, err := inmem.NewInmemHA(nil, logger) - if err != nil { - t.Fatal(err) - } - performTestSealUnwrapper(t, phys, logger) + // Test with both cache enabled and disabled + for _, disableCache := range []bool{true, false} { + // Test without transactions + phys, err := inmem.NewInmemHA(nil, logger) + if err != nil { + t.Fatal(err) + } + performTestSealUnwrapper(t, phys, logger, disableCache) - // Test with transactions - tPhys, err := inmem.NewTransactionalInmemHA(nil, logger) - if err != nil { - t.Fatal(err) + // Test with transactions + tPhys, err := inmem.NewTransactionalInmemHA(nil, logger) + if err != nil { + t.Fatal(err) + } + performTestSealUnwrapper(t, tPhys, logger, disableCache) } - performTestSealUnwrapper(t, tPhys, logger) } -func performTestSealUnwrapper(t *testing.T, phys physical.Backend, logger log.Logger) { +func performTestSealUnwrapper(t *testing.T, phys physical.Backend, logger log.Logger, disableCache bool) { ctx := context.Background() base := &CoreConfig{ - Physical: phys, + Physical: phys, + DisableCache: disableCache, } cluster := NewTestCluster(t, base, &TestClusterOptions{ Logger: logger, @@ -47,6 +51,8 @@ func performTestSealUnwrapper(t *testing.T, phys physical.Backend, logger log.Lo cluster.Start() defer cluster.Cleanup() + physImem := phys.(interface{ Underlying() *inmem.InmemBackend }).Underlying() + // Read a value and then save it back in a proto message entry, err := phys.Get(ctx, "core/master") if err != nil { @@ -78,7 +84,15 @@ func performTestSealUnwrapper(t *testing.T, phys physical.Backend, logger log.Lo // successfully decode it, but be able to unmarshal it when read back from // the underlying physical store. When we read from active, it should both // successfully decode it and persist it back. - checkValue := func(core *Core, wrapped bool) { + checkValue := func(core *Core, wrapped bool, ro bool) { + if ro { + physImem.FailPut(true) + physImem.FailDelete(true) + defer func() { + physImem.FailPut(false) + physImem.FailDelete(false) + }() + } entry, err := core.physical.Get(ctx, "core/master") if err != nil { t.Fatal(err) @@ -106,7 +120,12 @@ func performTestSealUnwrapper(t *testing.T, phys physical.Backend, logger log.Lo } TestWaitActive(t, cluster.Cores[0].Core) - checkValue(cluster.Cores[2].Core, true) - checkValue(cluster.Cores[1].Core, true) - checkValue(cluster.Cores[0].Core, false) + checkValue(cluster.Cores[2].Core, true, true) + checkValue(cluster.Cores[1].Core, true, true) + checkValue(cluster.Cores[0].Core, false, false) + + // The storage entry should now be unwrapped, so there should be no more writes to storage when we read it + checkValue(cluster.Cores[2].Core, false, true) + checkValue(cluster.Cores[1].Core, false, true) + checkValue(cluster.Cores[0].Core, false, true) } From 34dac4edd9dbfea76f60d4fb25a715aa97913db5 Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Mon, 2 Dec 2024 12:28:01 -0600 Subject: [PATCH 07/45] add enableMetadataOnFailures attr (#29068) --- ui/tests/helpers/openapi/expected-auth-attrs.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui/tests/helpers/openapi/expected-auth-attrs.js b/ui/tests/helpers/openapi/expected-auth-attrs.js index ad240eca28a9..f30077167dcd 100644 --- a/ui/tests/helpers/openapi/expected-auth-attrs.js +++ b/ui/tests/helpers/openapi/expected-auth-attrs.js @@ -180,6 +180,12 @@ const cert = { fieldGroup: 'default', type: 'boolean', }, + enableMetadataOnFailures: { + editType: 'boolean', + fieldGroup: 'default', + helpText: 'If set, metadata of the client certificate will be returned on authentication failures.', + type: 'boolean', + }, ocspCacheSize: { editType: 'number', helpText: 'The size of the in memory OCSP response cache, shared by all configured certs', From 6ed4ad08517c70918993d8712c10d7279af8b517 Mon Sep 17 00:00:00 2001 From: divyaac Date: Mon, 2 Dec 2024 11:44:03 -0800 Subject: [PATCH 08/45] Remove all references to current fragments, standbyfragments and partialMonthTracker (#29066) * Oss Changes Patch * Remove test from oss file --- builtin/logical/pki/acme_billing_test.go | 8 +- .../operator_usage_testonly_test.go | 2 +- sdk/helper/clientcountutil/clientcountutil.go | 27 +- .../clientcountutil/clientcountutil_test.go | 5 +- vault/activity_log.go | 468 ++++++++--------- vault/activity_log_test.go | 477 ++++++------------ vault/activity_log_testing_util.go | 84 ++- vault/activity_log_util_common.go | 19 +- vault/activity_log_util_common_test.go | 62 +-- .../acme_regeneration_test.go | 6 +- .../activity_testonly_oss_test.go | 2 +- .../activity_testonly_test.go | 24 +- .../logical_system_activity_write_testonly.go | 104 ++-- ...cal_system_activity_write_testonly_test.go | 167 ++++-- 14 files changed, 632 insertions(+), 823 deletions(-) diff --git a/builtin/logical/pki/acme_billing_test.go b/builtin/logical/pki/acme_billing_test.go index b1948d7be29c..f8db67e64478 100644 --- a/builtin/logical/pki/acme_billing_test.go +++ b/builtin/logical/pki/acme_billing_test.go @@ -104,15 +104,17 @@ func TestACMEBilling(t *testing.T) { expectedCount = validateClientCount(t, client, "ns2/pki", expectedCount+1, "unique identifier in a different namespace") // Check the current fragment - fragment := cluster.Cores[0].Core.ResetActivityLog()[0] - if fragment == nil { + localFragment, globalFragment := cluster.Cores[0].Core.ResetActivityLog() + if globalFragment == nil || localFragment == nil { t.Fatal("no fragment created") } - validateAcmeClientTypes(t, fragment, expectedCount) + validateAcmeClientTypes(t, localFragment[0], 0) + validateAcmeClientTypes(t, globalFragment[0], expectedCount) } func validateAcmeClientTypes(t *testing.T, fragment *activity.LogFragment, expectedCount int64) { t.Helper() + if int64(len(fragment.Clients)) != expectedCount { t.Fatalf("bad number of entities, expected %v: got %v, entities are: %v", expectedCount, len(fragment.Clients), fragment.Clients) } diff --git a/command/command_testonly/operator_usage_testonly_test.go b/command/command_testonly/operator_usage_testonly_test.go index 74d67291fd1f..4cdfc0536ac3 100644 --- a/command/command_testonly/operator_usage_testonly_test.go +++ b/command/command_testonly/operator_usage_testonly_test.go @@ -53,7 +53,7 @@ func TestOperatorUsageCommandRun(t *testing.T) { now := time.Now().UTC() - _, _, _, err = clientcountutil.NewActivityLogData(client). + _, _, err = clientcountutil.NewActivityLogData(client). NewPreviousMonthData(1). NewClientsSeen(6, clientcountutil.WithClientType("entity")). NewClientsSeen(4, clientcountutil.WithClientType("non-entity-token")). diff --git a/sdk/helper/clientcountutil/clientcountutil.go b/sdk/helper/clientcountutil/clientcountutil.go index d09c5be13d33..85b25dab4348 100644 --- a/sdk/helper/clientcountutil/clientcountutil.go +++ b/sdk/helper/clientcountutil/clientcountutil.go @@ -280,39 +280,30 @@ func (d *ActivityLogDataGenerator) ToProto() *generation.ActivityLogMockInput { } // Write writes the data to the API with the given write options. The method -// returns the new paths that have been written. Note that the API endpoint will +// returns the new local and global paths that have been written. Note that the API endpoint will // only be present when Vault has been compiled with the "testonly" flag. -func (d *ActivityLogDataGenerator) Write(ctx context.Context, writeOptions ...generation.WriteOptions) ([]string, []string, []string, error) { +func (d *ActivityLogDataGenerator) Write(ctx context.Context, writeOptions ...generation.WriteOptions) ([]string, []string, error) { d.data.Write = writeOptions err := VerifyInput(d.data) if err != nil { - return nil, nil, nil, err + return nil, nil, err } data, err := d.ToJSON() if err != nil { - return nil, nil, nil, err + return nil, nil, err } resp, err := d.client.Logical().WriteWithContext(ctx, "sys/internal/counters/activity/write", map[string]interface{}{"input": string(data)}) if err != nil { - return nil, nil, nil, err + return nil, nil, err } if resp.Data == nil { - return nil, nil, nil, fmt.Errorf("received no data") - } - paths := resp.Data["paths"] - castedPaths, ok := paths.([]interface{}) - if !ok { - return nil, nil, nil, fmt.Errorf("invalid paths data: %v", paths) - } - returnPaths := make([]string, 0, len(castedPaths)) - for _, path := range castedPaths { - returnPaths = append(returnPaths, path.(string)) + return nil, nil, fmt.Errorf("received no data") } localPaths := resp.Data["local_paths"] localCastedPaths, ok := localPaths.([]interface{}) if !ok { - return nil, nil, nil, fmt.Errorf("invalid local paths data: %v", localPaths) + return nil, nil, fmt.Errorf("invalid local paths data: %v", localPaths) } returnLocalPaths := make([]string, 0, len(localCastedPaths)) for _, path := range localCastedPaths { @@ -322,13 +313,13 @@ func (d *ActivityLogDataGenerator) Write(ctx context.Context, writeOptions ...ge globalPaths := resp.Data["global_paths"] globalCastedPaths, ok := globalPaths.([]interface{}) if !ok { - return nil, nil, nil, fmt.Errorf("invalid global paths data: %v", globalPaths) + return nil, nil, fmt.Errorf("invalid global paths data: %v", globalPaths) } returnGlobalPaths := make([]string, 0, len(globalCastedPaths)) for _, path := range globalCastedPaths { returnGlobalPaths = append(returnGlobalPaths, path.(string)) } - return returnPaths, returnLocalPaths, returnGlobalPaths, nil + return returnLocalPaths, returnGlobalPaths, nil } // VerifyInput checks that the input data is valid diff --git a/sdk/helper/clientcountutil/clientcountutil_test.go b/sdk/helper/clientcountutil/clientcountutil_test.go index 4ea987fed025..637407436503 100644 --- a/sdk/helper/clientcountutil/clientcountutil_test.go +++ b/sdk/helper/clientcountutil/clientcountutil_test.go @@ -116,7 +116,7 @@ func TestNewCurrentMonthData_AddClients(t *testing.T) { // sent to the server is correct. func TestWrite(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := io.WriteString(w, `{"data":{"paths":["path1","path2"],"global_paths":["path2","path3"], "local_paths":["path3","path4"]}}`) + _, err := io.WriteString(w, `{"data":{"global_paths":["path2","path3"], "local_paths":["path3","path4"]}}`) require.NoError(t, err) body, err := io.ReadAll(r.Body) require.NoError(t, err) @@ -131,7 +131,7 @@ func TestWrite(t *testing.T) { Address: ts.URL, }) require.NoError(t, err) - paths, localPaths, globalPaths, err := NewActivityLogData(client). + localPaths, globalPaths, err := NewActivityLogData(client). NewPreviousMonthData(3). NewClientSeen(). NewPreviousMonthData(2). @@ -140,7 +140,6 @@ func TestWrite(t *testing.T) { NewCurrentMonthData().Write(context.Background(), generation.WriteOptions_WRITE_ENTITIES) require.NoError(t, err) - require.Equal(t, []string{"path1", "path2"}, paths) require.Equal(t, []string{"path2", "path3"}, globalPaths) require.Equal(t, []string{"path3", "path4"}, localPaths) } diff --git a/vault/activity_log.go b/vault/activity_log.go index 3ad43d31b479..757165f3e1f1 100644 --- a/vault/activity_log.go +++ b/vault/activity_log.go @@ -51,7 +51,6 @@ const ( distinctClientsBasePath = "log/distinctclients/" // for testing purposes (public as needed) - ActivityLogPrefix = "sys/counters/activity/log/" ActivityGlobalLogPrefix = "sys/counters/activity/global/log/" ActivityLogLocalPrefix = "sys/counters/activity/local/log/" ActivityPrefix = "sys/counters/activity/" @@ -147,8 +146,7 @@ type ActivityLog struct { // Acquire "l" before fragmentLock, globalFragmentLock, and localFragmentLock if all must be held. l sync.RWMutex - // fragmentLock protects enable, partialMonthClientTracker, fragment, - // standbyFragmentsReceived. + // fragmentLock protects enable fragmentLock sync.RWMutex // localFragmentLock protects partialMonthLocalClientTracker, localFragment, @@ -180,9 +178,6 @@ type ActivityLog struct { // could be adapted to use a secondary in the future. nodeID string - // current log fragment (may be nil) - fragment *activity.LogFragment - // Channel to signal a new fragment has been created // so it's appropriate to start the timer. newFragmentCh chan struct{} @@ -210,9 +205,6 @@ type ActivityLog struct { // track metadata and contents of the most recent local log segment currentLocalSegment segmentInfo - // Fragments received from performance standbys - standbyFragmentsReceived []*activity.LogFragment - // Local fragments received from performance standbys standbyLocalFragmentsReceived []*activity.LogFragment @@ -238,9 +230,6 @@ type ActivityLog struct { // for testing: is config currently being invalidated. protected by l configInvalidationInProgress bool - // partialMonthClientTracker tracks active clients this month. Protected by fragmentLock. - partialMonthClientTracker map[string]*activity.EntityRecord - // partialMonthLocalClientTracker tracks active local clients this month. Protected by localFragmentLock. partialMonthLocalClientTracker map[string]*activity.EntityRecord @@ -370,7 +359,6 @@ func NewActivityLog(core *Core, logger log.Logger, view *BarrierView, metrics me newFragmentCh: make(chan struct{}, 1), sendCh: make(chan struct{}, 1), // buffered so it can be triggered by fragment size doneCh: make(chan struct{}, 1), - partialMonthClientTracker: make(map[string]*activity.EntityRecord), partialMonthLocalClientTracker: make(map[string]*activity.EntityRecord), newGlobalClientFragmentCh: make(chan struct{}, 1), globalPartialMonthClientTracker: make(map[string]*activity.EntityRecord), @@ -414,7 +402,6 @@ func NewActivityLog(core *Core, logger log.Logger, view *BarrierView, metrics me }, clientSequenceNumber: 0, }, - standbyFragmentsReceived: make([]*activity.LogFragment, 0), standbyLocalFragmentsReceived: make([]*activity.LogFragment, 0), standbyGlobalFragmentsReceived: make([]*activity.LogFragment, 0), secondaryGlobalClientFragments: make([]*activity.LogFragment, 0), @@ -462,14 +449,7 @@ func (a *ActivityLog) saveCurrentSegmentToStorageLocked(ctx context.Context, for defer a.metrics.MeasureSinceWithLabels([]string{"core", "activity", "segment_write"}, a.clock.Now(), []metricsutil.Label{}) - // Swap out the pending regular fragments - a.fragmentLock.Lock() - currentFragment := a.fragment - a.fragment = nil - standbys := a.standbyFragmentsReceived - a.standbyFragmentsReceived = make([]*activity.LogFragment, 0) - a.fragmentLock.Unlock() - + // Swap out the pending global fragments a.globalFragmentLock.Lock() secondaryGlobalClients := a.secondaryGlobalClientFragments a.secondaryGlobalClientFragments = make([]*activity.LogFragment, 0) @@ -505,14 +485,10 @@ func (a *ActivityLog) saveCurrentSegmentToStorageLocked(ctx context.Context, for // If segment start time is zero, do not update or write // (even if force is true). This can happen if activityLog is // disabled after a save as been triggered. - if a.currentSegment.startTimestamp == 0 { + if a.currentGlobalSegment.startTimestamp == 0 { return nil } - if ret := a.createCurrentSegmentFromFragments(ctx, append(standbys, currentFragment), &a.currentSegment, force, ""); ret != nil { - return ret - } - // If we are the primary, store global clients // Create fragments from global clients and store the segment if !a.core.IsPerfSecondary() { @@ -575,7 +551,7 @@ func (a *ActivityLog) createCurrentSegmentFromFragments(ctx context.Context, fra // month when the client upgrades to 1.9, we must retain this functionality. for ns, val := range f.NonEntityTokens { // We track these pre-1.9 values in the old location, which is - // a.currentSegment.tokenCount, as opposed to the counter that stores tokens + // currentSegment.tokenCount, as opposed to the counter that stores tokens // without entities that have client IDs, namely // a.partialMonthClientTracker.nonEntityCountByNamespaceID. This preserves backward // compatibility for the precomputedQueryWorkers and the segment storing @@ -736,7 +712,7 @@ func parseSegmentNumberFromPath(path string) (int, bool) { // sorted last to first func (a *ActivityLog) availableLogs(ctx context.Context, upTo time.Time) ([]time.Time, error) { paths := make([]string, 0) - for _, basePath := range []string{activityEntityBasePath, activityLocalPathPrefix + activityEntityBasePath, activityGlobalPathPrefix + activityEntityBasePath, activityTokenLocalBasePath} { + for _, basePath := range []string{activityLocalPathPrefix + activityEntityBasePath, activityGlobalPathPrefix + activityEntityBasePath, activityTokenLocalBasePath} { p, err := a.view.List(ctx, basePath) if err != nil { return nil, err @@ -785,21 +761,17 @@ func (a *ActivityLog) getMostRecentActivityLogSegment(ctx context.Context, now t } // getLastEntitySegmentNumber returns the (non-negative) last segment number for the :startTime:, if it exists -func (a *ActivityLog) getLastEntitySegmentNumber(ctx context.Context, startTime time.Time) (uint64, uint64, uint64, bool, error) { - segmentHighestNum, segmentPresent, err := a.getLastSegmentNumberByEntityPath(ctx, activityEntityBasePath+fmt.Sprint(startTime.Unix())+"/") - if err != nil { - return 0, 0, 0, false, err - } +func (a *ActivityLog) getLastEntitySegmentNumber(ctx context.Context, startTime time.Time) (uint64, uint64, bool, error) { globalHighestNum, globalSegmentPresent, err := a.getLastSegmentNumberByEntityPath(ctx, activityGlobalPathPrefix+activityEntityBasePath+fmt.Sprint(startTime.Unix())+"/") if err != nil { - return 0, 0, 0, false, err + return 0, 0, false, err } localHighestNum, localSegmentPresent, err := a.getLastSegmentNumberByEntityPath(ctx, activityLocalPathPrefix+activityEntityBasePath+fmt.Sprint(startTime.Unix())+"/") if err != nil { - return 0, 0, 0, false, err + return 0, 0, false, err } - return segmentHighestNum, uint64(localHighestNum), uint64(globalHighestNum), (segmentPresent || localSegmentPresent || globalSegmentPresent), nil + return uint64(localHighestNum), uint64(globalHighestNum), (localSegmentPresent || globalSegmentPresent), nil } func (a *ActivityLog) getLastSegmentNumberByEntityPath(ctx context.Context, entityPath string) (uint64, bool, error) { @@ -829,30 +801,33 @@ func (a *ActivityLog) getLastSegmentNumberByEntityPath(ctx context.Context, enti // WalkEntitySegments loads each of the entity segments for a particular start time func (a *ActivityLog) WalkEntitySegments(ctx context.Context, startTime time.Time, hll *hyperloglog.Sketch, walkFn func(*activity.EntityActivityLog, time.Time, *hyperloglog.Sketch) error) error { - basePath := activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" - pathList, err := a.view.List(ctx, basePath) - if err != nil { - return err - } + baseGlobalPath := activityGlobalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + baseLocalPath := activityLocalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" - for _, path := range pathList { - raw, err := a.view.Get(ctx, basePath+path) + for _, basePath := range []string{baseGlobalPath, baseLocalPath} { + pathList, err := a.view.List(ctx, basePath) if err != nil { return err } - if raw == nil { - a.logger.Warn("expected log segment not found", "startTime", startTime, "segment", path) - continue - } + for _, path := range pathList { + raw, err := a.view.Get(ctx, basePath+path) + if err != nil { + return err + } + if raw == nil { + a.logger.Warn("expected log segment not found", "startTime", startTime, "segment", path) + continue + } - out := &activity.EntityActivityLog{} - err = proto.Unmarshal(raw.Value, out) - if err != nil { - return fmt.Errorf("unable to parse segment %v%v: %w", basePath, path, err) - } - err = walkFn(out, startTime, hll) - if err != nil { - return fmt.Errorf("unable to walk entities: %w", err) + out := &activity.EntityActivityLog{} + err = proto.Unmarshal(raw.Value, out) + if err != nil { + return fmt.Errorf("unable to parse segment %v%v: %w", basePath, path, err) + } + err = walkFn(out, startTime, hll) + if err != nil { + return fmt.Errorf("unable to walk entities: %w", err) + } } } return nil @@ -889,69 +864,53 @@ func (a *ActivityLog) WalkTokenSegments(ctx context.Context, } // loadPriorEntitySegment populates the in-memory tracker for entity IDs that have -// been active "this month" -func (a *ActivityLog) loadPriorEntitySegment(ctx context.Context, startTime time.Time, sequenceNum uint64) error { - path := activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(sequenceNum, 10) - data, err := a.view.Get(ctx, path) - if err != nil { - return err - } - if data == nil { - return nil - } - - out := &activity.EntityActivityLog{} - err = proto.Unmarshal(data.Value, out) - if err != nil { - return err - } - +// been active "this month". If the entity segment to load is global, globalPartialMonthClientTracker +// is updated else partialMonthLocalClientTracker gets updated. +func (a *ActivityLog) loadPriorEntitySegment(ctx context.Context, startTime time.Time, sequenceNum uint64, isLocal bool) error { a.l.RLock() defer a.l.RUnlock() + + // protecting a.enabled a.fragmentLock.Lock() - // Handle the (unlikely) case where the end of the month has been reached while background loading. - // Or the feature has been disabled. - if a.enabled && startTime.Unix() == a.currentSegment.startTimestamp { - for _, ent := range out.Clients { - a.partialMonthClientTracker[ent.ClientID] = ent - } - } - a.fragmentLock.Unlock() + defer a.fragmentLock.Unlock() // load all the active global clients - globalPath := activityGlobalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(sequenceNum, 10) - data, err = a.view.Get(ctx, globalPath) - if err != nil { - return err - } - if data == nil { - return nil - } - out = &activity.EntityActivityLog{} - err = proto.Unmarshal(data.Value, out) - if err != nil { - return err - } - a.globalFragmentLock.Lock() - // Handle the (unlikely) case where the end of the month has been reached while background loading. - // Or the feature has been disabled. - if a.enabled && startTime.Unix() == a.currentGlobalSegment.startTimestamp { - for _, ent := range out.Clients { - a.globalPartialMonthClientTracker[ent.ClientID] = ent + if !isLocal { + globalPath := activityGlobalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(sequenceNum, 10) + data, err := a.view.Get(ctx, globalPath) + if err != nil { + return err + } + if data == nil { + return nil + } + out := &activity.EntityActivityLog{} + err = proto.Unmarshal(data.Value, out) + if err != nil { + return err } + a.globalFragmentLock.Lock() + // Handle the (unlikely) case where the end of the month has been reached while background loading. + // Or the feature has been disabled. + if a.enabled && startTime.Unix() == a.currentGlobalSegment.startTimestamp { + for _, ent := range out.Clients { + a.globalPartialMonthClientTracker[ent.ClientID] = ent + } + } + a.globalFragmentLock.Unlock() + return nil } - a.globalFragmentLock.Unlock() // load all the active local clients localPath := activityLocalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(sequenceNum, 10) - data, err = a.view.Get(ctx, localPath) + data, err := a.view.Get(ctx, localPath) if err != nil { return err } if data == nil { return nil } - out = &activity.EntityActivityLog{} + out := &activity.EntityActivityLog{} err = proto.Unmarshal(data.Value, out) if err != nil { return err @@ -970,75 +929,44 @@ func (a *ActivityLog) loadPriorEntitySegment(ctx context.Context, startTime time } // loadCurrentClientSegment loads the most recent segment (for "this month") -// into memory (to append new entries), and to the partialMonthClientTracker to +// into memory (to append new entries), and to the globalPartialMonthClientTracker and partialMonthLocalClientTracker to // avoid duplication call with fragmentLock, globalFragmentLock, localFragmentLock and l held. -func (a *ActivityLog) loadCurrentClientSegment(ctx context.Context, startTime time.Time, sequenceNum uint64, localSegmentSequenceNumber uint64, globalSegmentSequenceNumber uint64) error { - path := activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(sequenceNum, 10) - data, err := a.view.Get(ctx, path) - if err != nil { - return err - } - if data == nil { - return nil - } - - out := &activity.EntityActivityLog{} - err = proto.Unmarshal(data.Value, out) - if err != nil { - return err - } - - if !a.core.perfStandby { - a.currentSegment = segmentInfo{ - startTimestamp: startTime.Unix(), - currentClients: &activity.EntityActivityLog{ - Clients: out.Clients, - }, - tokenCount: a.currentSegment.tokenCount, - clientSequenceNumber: sequenceNum, - } - } else { - // populate this for edge case checking (if end of month passes while background loading on standby) - a.currentSegment.startTimestamp = startTime.Unix() - } - - for _, client := range out.Clients { - a.partialMonthClientTracker[client.ClientID] = client - } - +func (a *ActivityLog) loadCurrentClientSegment(ctx context.Context, startTime time.Time, localSegmentSequenceNumber uint64, globalSegmentSequenceNumber uint64) error { // load current global segment - path = activityGlobalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(globalSegmentSequenceNumber, 10) - data, err = a.view.Get(ctx, path) - if err != nil { - return err - } - if data == nil { - return nil - } + path := activityGlobalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(globalSegmentSequenceNumber, 10) - out = &activity.EntityActivityLog{} - err = proto.Unmarshal(data.Value, out) + // setting a.currentSegment timestamp to support upgrades + a.currentSegment.startTimestamp = startTime.Unix() + + data, err := a.view.Get(ctx, path) if err != nil { return err } + if data != nil { + out := &activity.EntityActivityLog{} + err = proto.Unmarshal(data.Value, out) + if err != nil { + return err + } - if !a.core.perfStandby { - a.currentGlobalSegment = segmentInfo{ - startTimestamp: startTime.Unix(), - currentClients: &activity.EntityActivityLog{ - Clients: out.Clients, - }, - tokenCount: &activity.TokenCount{ - CountByNamespaceID: make(map[string]uint64), - }, - clientSequenceNumber: sequenceNum, + if !a.core.perfStandby { + a.currentGlobalSegment = segmentInfo{ + startTimestamp: startTime.Unix(), + currentClients: &activity.EntityActivityLog{ + Clients: out.Clients, + }, + tokenCount: &activity.TokenCount{ + CountByNamespaceID: make(map[string]uint64), + }, + clientSequenceNumber: globalSegmentSequenceNumber, + } + } else { + // populate this for edge case checking (if end of month passes while background loading on standby) + a.currentGlobalSegment.startTimestamp = startTime.Unix() + } + for _, client := range out.Clients { + a.globalPartialMonthClientTracker[client.ClientID] = client } - } else { - // populate this for edge case checking (if end of month passes while background loading on standby) - a.currentGlobalSegment.startTimestamp = startTime.Unix() - } - for _, client := range out.Clients { - a.globalPartialMonthClientTracker[client.ClientID] = client } // load current local segment @@ -1047,31 +975,30 @@ func (a *ActivityLog) loadCurrentClientSegment(ctx context.Context, startTime ti if err != nil { return err } - if data == nil { - return nil - } - - out = &activity.EntityActivityLog{} - err = proto.Unmarshal(data.Value, out) - if err != nil { - return err - } + if data != nil { + out := &activity.EntityActivityLog{} + err = proto.Unmarshal(data.Value, out) + if err != nil { + return err + } - if !a.core.perfStandby { - a.currentLocalSegment = segmentInfo{ - startTimestamp: startTime.Unix(), - currentClients: &activity.EntityActivityLog{ - Clients: out.Clients, - }, - tokenCount: a.currentLocalSegment.tokenCount, - clientSequenceNumber: sequenceNum, + if !a.core.perfStandby { + a.currentLocalSegment = segmentInfo{ + startTimestamp: startTime.Unix(), + currentClients: &activity.EntityActivityLog{ + Clients: out.Clients, + }, + tokenCount: a.currentLocalSegment.tokenCount, + clientSequenceNumber: localSegmentSequenceNumber, + } + } else { + // populate this for edge case checking (if end of month passes while background loading on standby) + a.currentLocalSegment.startTimestamp = startTime.Unix() } - } else { - // populate this for edge case checking (if end of month passes while background loading on standby) - a.currentLocalSegment.startTimestamp = startTime.Unix() - } - for _, client := range out.Clients { - a.partialMonthLocalClientTracker[client.ClientID] = client + for _, client := range out.Clients { + a.partialMonthLocalClientTracker[client.ClientID] = client + } + } return nil @@ -1128,14 +1055,15 @@ func (a *ActivityLog) loadTokenCount(ctx context.Context, startTime time.Time) e // We must load the tokenCount of the current segment into the activity log // so that TWEs counted before the introduction of a client ID for TWEs are // still reported in the partial client counts. - a.currentSegment.tokenCount = out a.currentLocalSegment.tokenCount = out return nil } -// entityBackgroundLoader loads entity activity log records for start_date `t` -func (a *ActivityLog) entityBackgroundLoader(ctx context.Context, wg *sync.WaitGroup, t time.Time, seqNums <-chan uint64) { +// entityBackgroundLoader loads entity activity log records for start_date `t`. +// If isLocal is true, it loads the local entity activity log records else it +// loads global entity activity log records. +func (a *ActivityLog) entityBackgroundLoader(ctx context.Context, wg *sync.WaitGroup, t time.Time, seqNums <-chan uint64, isLocal bool) { defer wg.Done() for seqNum := range seqNums { select { @@ -1145,7 +1073,7 @@ func (a *ActivityLog) entityBackgroundLoader(ctx context.Context, wg *sync.WaitG default: } - err := a.loadPriorEntitySegment(ctx, t, seqNum) + err := a.loadPriorEntitySegment(ctx, t, seqNum, isLocal) if err != nil { a.logger.Error("error loading entity activity log", "time", t, "sequence", seqNum, "err", err) } @@ -1169,7 +1097,7 @@ func (a *ActivityLog) newMonthCurrentLogLocked(currentTime time.Time) { } // Initialize a new current segment, based on the given time -// should be called with fragmentLock, globalFragmentLock, localFragmentLock and l held. +// should be called with globalFragmentLock, localFragmentLock and l held. func (a *ActivityLog) newSegmentAtGivenTime(t time.Time) { timestamp := t.Unix() @@ -1182,26 +1110,17 @@ func (a *ActivityLog) newSegmentAtGivenTime(t time.Time) { // should be called with l held. func (a *ActivityLog) setCurrentSegmentTimeLocked(t time.Time) { timestamp := t.Unix() - a.currentSegment.startTimestamp = timestamp a.currentGlobalSegment.startTimestamp = timestamp a.currentLocalSegment.startTimestamp = timestamp + // setting a.currentSegment timestamp to support upgrades + a.currentSegment.startTimestamp = timestamp } // Reset all the current segment state. -// Should be called with fragmentLock, globalFragmentLock, localFragmentLock and l held. +// Should be called with globalFragmentLock, localFragmentLock and l held. func (a *ActivityLog) resetCurrentLog() { + // setting a.currentSegment timestamp to support upgrades a.currentSegment.startTimestamp = 0 - a.currentSegment.currentClients = &activity.EntityActivityLog{ - Clients: make([]*activity.EntityRecord, 0), - } - - // We must still initialize the tokenCount to recieve tokenCounts from fragments - // during the month where customers upgrade to 1.9 - a.currentSegment.tokenCount = &activity.TokenCount{ - CountByNamespaceID: make(map[string]uint64), - } - - a.currentSegment.clientSequenceNumber = 0 // global segment a.currentGlobalSegment.startTimestamp = 0 @@ -1217,16 +1136,12 @@ func (a *ActivityLog) resetCurrentLog() { } a.currentLocalSegment.clientSequenceNumber = 0 - a.fragment = nil - a.partialMonthClientTracker = make(map[string]*activity.EntityRecord) - a.currentGlobalFragment = nil a.globalPartialMonthClientTracker = make(map[string]*activity.EntityRecord) a.localFragment = nil a.partialMonthLocalClientTracker = make(map[string]*activity.EntityRecord) - a.standbyFragmentsReceived = make([]*activity.LogFragment, 0) a.standbyLocalFragmentsReceived = make([]*activity.LogFragment, 0) a.standbyGlobalFragmentsReceived = make([]*activity.LogFragment, 0) a.secondaryGlobalClientFragments = make([]*activity.LogFragment, 0) @@ -1234,7 +1149,6 @@ func (a *ActivityLog) resetCurrentLog() { func (a *ActivityLog) deleteLogWorker(ctx context.Context, startTimestamp int64, whenDone chan struct{}) { entityPathsToDelete := make([]string, 0) - entityPathsToDelete = append(entityPathsToDelete, fmt.Sprintf("%v%v/", activityEntityBasePath, startTimestamp)) entityPathsToDelete = append(entityPathsToDelete, fmt.Sprintf("%s%v%v/", activityGlobalPathPrefix, activityEntityBasePath, startTimestamp)) entityPathsToDelete = append(entityPathsToDelete, fmt.Sprintf("%s%v%v/", activityLocalPathPrefix, activityEntityBasePath, startTimestamp)) entityPathsToDelete = append(entityPathsToDelete, fmt.Sprintf("%v%v/", activityTokenLocalBasePath, startTimestamp)) @@ -1350,7 +1264,7 @@ func (a *ActivityLog) refreshFromStoredLog(ctx context.Context, wg *sync.WaitGro } // load entity logs from storage into memory - lastSegment, localLastSegment, globalLastSegment, segmentsExist, err := a.getLastEntitySegmentNumber(ctx, mostRecent) + localLastSegment, globalLastSegment, segmentsExist, err := a.getLastEntitySegmentNumber(ctx, mostRecent) if err != nil { return err } @@ -1359,20 +1273,39 @@ func (a *ActivityLog) refreshFromStoredLog(ctx context.Context, wg *sync.WaitGro return nil } - err = a.loadCurrentClientSegment(ctx, mostRecent, lastSegment, localLastSegment, globalLastSegment) - if err != nil || lastSegment == 0 { + err = a.loadCurrentClientSegment(ctx, mostRecent, localLastSegment, globalLastSegment) + // if both localLastSegment and globalLastSegment are 0, it will return nil here + if err != nil || (localLastSegment == 0 && globalLastSegment == 0) { return err } - lastSegment-- - seqNums := make(chan uint64, lastSegment+1) - wg.Add(1) - go a.entityBackgroundLoader(ctx, wg, mostRecent, seqNums) + // if last local segment that got loaded using loadCurrentClientSegment is not 0, there are more local segments to load + if localLastSegment != 0 { + localLastSegment-- + + localSeqNums := make(chan uint64, localLastSegment+1) + wg.Add(1) + go a.entityBackgroundLoader(ctx, wg, mostRecent, localSeqNums, true) + + for n := int(localLastSegment); n >= 0; n-- { + localSeqNums <- uint64(n) + } + close(localSeqNums) + } + + // if last global segment that got loaded using loadCurrentClientSegment is not 0, there are more global segments to load + if globalLastSegment != 0 { + globalLastSegment-- - for n := int(lastSegment); n >= 0; n-- { - seqNums <- uint64(n) + globalSeqNums := make(chan uint64, globalLastSegment+1) + wg.Add(1) + go a.entityBackgroundLoader(ctx, wg, mostRecent, globalSeqNums, false) + + for n := int(globalLastSegment); n >= 0; n-- { + globalSeqNums <- uint64(n) + } + close(globalSeqNums) } - close(seqNums) return nil } @@ -1425,16 +1358,16 @@ func (a *ActivityLog) SetConfig(ctx context.Context, config activityConfig) { a.logger.Info("activity log enable changed", "original", originalEnabled, "current", a.enabled) } - if !a.enabled && a.currentSegment.startTimestamp != 0 && a.currentGlobalSegment.startTimestamp != 0 && a.currentLocalSegment.startTimestamp != 0 { + if !a.enabled && a.currentGlobalSegment.startTimestamp != 0 && a.currentLocalSegment.startTimestamp != 0 { a.logger.Trace("deleting current segment") a.deleteDone = make(chan struct{}) // this is called from a request under stateLock, so use activeContext - go a.deleteLogWorker(a.core.activeContext, a.currentSegment.startTimestamp, a.deleteDone) + go a.deleteLogWorker(a.core.activeContext, a.currentGlobalSegment.startTimestamp, a.deleteDone) a.resetCurrentLog() } forceSave := false - if a.enabled && a.currentSegment.startTimestamp == 0 && a.currentGlobalSegment.startTimestamp == 0 && a.currentLocalSegment.startTimestamp == 0 { + if a.enabled && a.currentGlobalSegment.startTimestamp == 0 && a.currentLocalSegment.startTimestamp == 0 { a.startNewCurrentLogLocked(a.clock.Now().UTC()) // Force a save so we can distinguish between // @@ -1453,7 +1386,6 @@ func (a *ActivityLog) SetConfig(ctx context.Context, config activityConfig) { if forceSave { // l is still held here - a.saveCurrentSegmentInternal(ctx, true, a.currentSegment, "") a.saveCurrentSegmentInternal(ctx, true, a.currentGlobalSegment, activityGlobalPathPrefix) a.saveCurrentSegmentInternal(ctx, true, a.currentLocalSegment, activityLocalPathPrefix) } @@ -1690,10 +1622,10 @@ func (a *ActivityLog) StartOfNextMonth() time.Time { a.l.RLock() defer a.l.RUnlock() var segmentStart time.Time - if a.currentSegment.startTimestamp == 0 { + if a.currentGlobalSegment.startTimestamp == 0 { segmentStart = a.clock.Now().UTC() } else { - segmentStart = time.Unix(a.currentSegment.startTimestamp, 0).UTC() + segmentStart = time.Unix(a.currentGlobalSegment.startTimestamp, 0).UTC() } // Basing this on the segment start will mean we trigger EOM rollover when // necessary because we were down. @@ -1868,12 +1800,6 @@ func (a *ActivityLog) perfStandbyFragmentWorker(ctx context.Context) { } sendFunc() - // clear active entity set - a.fragmentLock.Lock() - a.partialMonthClientTracker = make(map[string]*activity.EntityRecord) - - a.fragmentLock.Unlock() - // clear local active entity set a.localFragmentLock.Lock() a.partialMonthLocalClientTracker = make(map[string]*activity.EntityRecord) @@ -1990,7 +1916,7 @@ func (a *ActivityLog) HandleEndOfMonth(ctx context.Context, currentTime time.Tim a.logger.Trace("starting end of month processing", "rolloverTime", currentTime) - err := a.writeIntentLog(ctx, a.currentSegment.startTimestamp, currentTime) + err := a.writeIntentLog(ctx, a.currentGlobalSegment.startTimestamp, currentTime) if err != nil { return err } @@ -2049,42 +1975,38 @@ func (a *ActivityLog) writeIntentLog(ctx context.Context, prevSegmentTimestamp i return nil } -// ResetActivityLog is used to extract the current fragment(s) during +// ResetActivityLog is used to extract the current local and global fragment(s) during // integration testing, so that it can be checked in a race-free way. -func (c *Core) ResetActivityLog() []*activity.LogFragment { +func (c *Core) ResetActivityLog() ([]*activity.LogFragment, []*activity.LogFragment) { c.stateLock.RLock() a := c.activityLog c.stateLock.RUnlock() if a == nil { - return nil + return nil, nil } - allFragments := make([]*activity.LogFragment, 1) - a.fragmentLock.Lock() - - allFragments[0] = a.fragment - a.fragment = nil - allFragments = append(allFragments, a.standbyFragmentsReceived...) - a.standbyFragmentsReceived = make([]*activity.LogFragment, 0) - a.secondaryGlobalClientFragments = make([]*activity.LogFragment, 0) - a.partialMonthClientTracker = make(map[string]*activity.EntityRecord) - a.fragmentLock.Unlock() + localFragments := make([]*activity.LogFragment, 0) + globalFragments := make([]*activity.LogFragment, 0) // local fragments a.localFragmentLock.Lock() - allFragments = append(allFragments, a.localFragment) + localFragments = append(localFragments, a.localFragment) a.localFragment = nil - allFragments = append(allFragments, a.standbyLocalFragmentsReceived...) + localFragments = append(localFragments, a.standbyLocalFragmentsReceived...) a.standbyLocalFragmentsReceived = make([]*activity.LogFragment, 0) a.partialMonthLocalClientTracker = make(map[string]*activity.EntityRecord) a.localFragmentLock.Unlock() // global fragments a.globalFragmentLock.Lock() + globalFragments = append(globalFragments, a.currentGlobalFragment) + a.currentGlobalFragment = nil + globalFragments = append(globalFragments, a.standbyGlobalFragmentsReceived...) a.globalPartialMonthClientTracker = make(map[string]*activity.EntityRecord) a.standbyGlobalFragmentsReceived = make([]*activity.LogFragment, 0) + a.secondaryGlobalClientFragments = make([]*activity.LogFragment, 0) a.globalFragmentLock.Unlock() - return allFragments + return localFragments, globalFragments } func (a *ActivityLog) AddEntityToFragment(entityID string, namespaceID string, timestamp int64) { @@ -2121,7 +2043,7 @@ func (a *ActivityLog) AddActivityToFragment(clientID string, namespaceID string, a.fragmentLock.RLock() if a.enabled { - _, presentInRegularClientMap := a.partialMonthClientTracker[clientID] + _, presentInRegularClientMap := a.globalPartialMonthClientTracker[clientID] _, presentInLocalClientmap := a.partialMonthLocalClientTracker[clientID] if presentInRegularClientMap || presentInLocalClientmap { present = true @@ -2146,7 +2068,7 @@ func (a *ActivityLog) AddActivityToFragment(clientID string, namespaceID string, defer a.globalFragmentLock.Unlock() // Re-check entity ID after re-acquiring lock - _, presentInRegularClientMap := a.partialMonthClientTracker[clientID] + _, presentInRegularClientMap := a.globalPartialMonthClientTracker[clientID] _, presentInLocalClientmap := a.partialMonthLocalClientTracker[clientID] if presentInRegularClientMap || presentInLocalClientmap { present = true @@ -2174,10 +2096,6 @@ func (a *ActivityLog) AddActivityToFragment(clientID string, namespaceID string, clientRecord.NonEntity = true } - // add the clients to the regular fragment - a.fragment.Clients = append(a.fragment.Clients, clientRecord) - a.partialMonthClientTracker[clientRecord.ClientID] = clientRecord - if local, _ := a.isClientLocal(clientRecord); local { // If the client is local then add the client to the current local fragment a.localFragment.Clients = append(a.localFragment.Clients, clientRecord) @@ -2212,17 +2130,10 @@ func (a *ActivityLog) isClientLocal(client *activity.EntityRecord) (bool, error) return false, nil } -// Create the fragments (regular fragment, local fragment and global fragment) if it doesn't already exist. +// Create the fragments (local fragment and global fragment) if it doesn't already exist. // Must be called with the fragmentLock, localFragmentLock and globalFragmentLock held. func (a *ActivityLog) createCurrentFragment() { - if a.fragment == nil { - // create regular fragment - a.fragment = &activity.LogFragment{ - OriginatingNode: a.nodeID, - Clients: make([]*activity.EntityRecord, 0, 120), - NonEntityTokens: make(map[string]uint64), - } - + if a.currentGlobalFragment == nil { // create local fragment a.localFragment = &activity.LogFragment{ OriginatingNode: a.nodeID, @@ -2232,6 +2143,7 @@ func (a *ActivityLog) createCurrentFragment() { // create global fragment a.currentGlobalFragment = &activity.LogFragment{ + OriginatingNode: a.nodeID, OriginatingCluster: a.core.ClusterID(), Clients: make([]*activity.EntityRecord, 0), } @@ -2293,7 +2205,6 @@ func (a *ActivityLog) receivedFragment(fragment *activity.LogFragment) { } for _, e := range fragment.Clients { - a.partialMonthClientTracker[e.ClientID] = e if isLocalFragment { a.partialMonthLocalClientTracker[e.ClientID] = e } else { @@ -2301,8 +2212,6 @@ func (a *ActivityLog) receivedFragment(fragment *activity.LogFragment) { } } - a.standbyFragmentsReceived = append(a.standbyFragmentsReceived, fragment) - if isLocalFragment { a.standbyLocalFragmentsReceived = append(a.standbyLocalFragmentsReceived, fragment) } else { @@ -2966,7 +2875,7 @@ func (a *ActivityLog) segmentToPrecomputedQuery(ctx context.Context, segmentTime // Iterate through entities, adding them to the hyperloglog and the summary maps in opts for { - entity, err := reader.ReadEntity(ctx) + entity, err := reader.ReadGlobalEntity(ctx) if errors.Is(err, io.EOF) { break } @@ -2981,6 +2890,23 @@ func (a *ActivityLog) segmentToPrecomputedQuery(ctx context.Context, segmentTime } } + for { + entity, err := reader.ReadLocalEntity(ctx) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + a.logger.Warn("failed to read segment", "error", err) + return err + } + err = a.handleEntitySegment(entity, segmentTime, hyperloglog, opts) + if err != nil { + a.logger.Warn("failed to handle entity segment", "error", err) + return err + } + + } + // Store the hyperloglog err = a.StoreHyperlogLog(ctx, segmentTime, hyperloglog) if err != nil { @@ -3133,7 +3059,7 @@ func (a *ActivityLog) precomputedQueryWorker(ctx context.Context, intent *Activi // too old, and startTimestamp should only go forward (unless it is zero.) // If there's an intent log, finish it even if the feature is currently disabled. a.l.RLock() - currentMonth := a.currentSegment.startTimestamp + currentMonth := a.currentGlobalSegment.startTimestamp // Base retention period on the month we are generating (even in the past)--- a.clock.Now() // would work but this will be easier to control in tests. retentionWindow := timeutil.MonthsPreviousTo(a.retentionMonths, time.Unix(intent.NextMonth, 0).UTC()) @@ -3272,7 +3198,7 @@ func (a *ActivityLog) PartialMonthMetrics(ctx context.Context) ([]metricsutil.Ga // Empty list return []metricsutil.GaugeLabelValues{}, nil } - count := len(a.partialMonthClientTracker) + count := len(a.globalPartialMonthClientTracker) + len(a.partialMonthLocalClientTracker) return []metricsutil.GaugeLabelValues{ { @@ -3298,7 +3224,7 @@ func (a *ActivityLog) populateNamespaceAndMonthlyBreakdowns() (map[int64]*proces // Parse the monthly clients and prepare the breakdowns. byNamespace := make(map[string]*processByNamespace) byMonth := make(map[int64]*processMonth) - for _, e := range a.partialMonthClientTracker { + for _, e := range a.globalPartialMonthClientTracker { processClientRecord(e, byNamespace, byMonth, a.clock.Now()) } for _, e := range a.partialMonthLocalClientTracker { diff --git a/vault/activity_log_test.go b/vault/activity_log_test.go index 4742d11467b8..1f36a7856582 100644 --- a/vault/activity_log_test.go +++ b/vault/activity_log_test.go @@ -34,7 +34,7 @@ import ( "github.com/stretchr/testify/require" ) -// TestActivityLog_Creation calls AddEntityToFragment and verifies that it appears correctly in a.fragment. +// TestActivityLog_Creation calls AddEntityToFragment and verifies that it appears correctly in a.currentGlobalFragment. func TestActivityLog_Creation(t *testing.T) { storage := &logical.InmemStorage{} coreConfig := &CoreConfig{ @@ -56,11 +56,13 @@ func TestActivityLog_Creation(t *testing.T) { if a.logger == nil || a.view == nil { t.Fatal("activity log not initialized") } - if a.fragment != nil || a.currentGlobalFragment != nil { - t.Fatal("activity log already has fragment") + currentGlobalFragment := core.GetActiveGlobalFragment() + if currentGlobalFragment != nil { + t.Fatal("activity log already has global fragment") } - if a.localFragment != nil { + localFragment := core.GetActiveLocalFragment() + if localFragment != nil { t.Fatal("activity log already has a local fragment") } @@ -69,44 +71,29 @@ func TestActivityLog_Creation(t *testing.T) { ts := time.Now() a.AddEntityToFragment(entity_id, namespace_id, ts.Unix()) - if a.fragment == nil || a.currentGlobalFragment == nil { + currentGlobalFragment = core.GetActiveGlobalFragment() + localFragment = core.GetActiveLocalFragment() + + if currentGlobalFragment == nil { t.Fatal("no fragment created") } - if a.fragment.OriginatingNode != a.nodeID { - t.Errorf("mismatched node ID, %q vs %q", a.fragment.OriginatingNode, a.nodeID) + if a.currentGlobalFragment.OriginatingNode != a.nodeID { + t.Errorf("mismatched node ID, %q vs %q", currentGlobalFragment.OriginatingNode, a.nodeID) } - if a.currentGlobalFragment.OriginatingCluster != a.core.ClusterID() { - t.Errorf("mismatched cluster ID, %q vs %q", a.currentGlobalFragment.GetOriginatingCluster(), a.core.ClusterID()) + if currentGlobalFragment.OriginatingCluster != a.core.ClusterID() { + t.Errorf("mismatched cluster ID, %q vs %q", currentGlobalFragment.GetOriginatingCluster(), a.core.ClusterID()) } - if a.fragment.Clients == nil || a.currentGlobalFragment.Clients == nil { + if currentGlobalFragment.Clients == nil { t.Fatal("no fragment entity slice") } - if a.fragment.NonEntityTokens == nil { - t.Fatal("no fragment token map") - } - - if len(a.fragment.Clients) != 1 { - t.Fatalf("wrong number of entities %v", len(a.fragment.Clients)) - } - if len(a.currentGlobalFragment.Clients) != 1 { - t.Fatalf("wrong number of entities %v", len(a.currentGlobalFragment.Clients)) + if len(currentGlobalFragment.Clients) != 1 { + t.Fatalf("wrong number of entities %v", len(currentGlobalFragment.Clients)) } - er := a.fragment.Clients[0] - if er.ClientID != entity_id { - t.Errorf("mimatched entity ID, %q vs %q", er.ClientID, entity_id) - } - if er.NamespaceID != namespace_id { - t.Errorf("mimatched namespace ID, %q vs %q", er.NamespaceID, namespace_id) - } - if er.Timestamp != ts.Unix() { - t.Errorf("mimatched timestamp, %v vs %v", er.Timestamp, ts.Unix()) - } - - er = a.currentGlobalFragment.Clients[0] + er := currentGlobalFragment.Clients[0] if er.ClientID != entity_id { t.Errorf("mimatched entity ID, %q vs %q", er.ClientID, entity_id) } @@ -118,22 +105,14 @@ func TestActivityLog_Creation(t *testing.T) { } // Reset and test the other code path - a.fragment = nil a.AddTokenToFragment(namespace_id) + currentGlobalFragment = core.GetActiveGlobalFragment() + localFragment = core.GetActiveLocalFragment() - if a.fragment == nil { + if currentGlobalFragment == nil { t.Fatal("no fragment created") } - if a.fragment.NonEntityTokens == nil { - t.Fatal("no fragment token map") - } - - actual := a.fragment.NonEntityTokens[namespace_id] - if actual != 1 { - t.Errorf("mismatched number of tokens, %v vs %v", actual, 1) - } - // test local fragment localMe := &MountEntry{ Table: credentialTableType, @@ -149,24 +128,25 @@ func TestActivityLog_Creation(t *testing.T) { local_ts := time.Now() a.AddClientToFragment(local_entity_id, "root", local_ts.Unix(), false, "local_mount_accessor") + localFragment = core.GetActiveLocalFragment() - if a.localFragment.OriginatingNode != a.nodeID { - t.Errorf("mismatched node ID, %q vs %q", a.localFragment.OriginatingNode, a.nodeID) + if localFragment.OriginatingNode != a.nodeID { + t.Errorf("mismatched node ID, %q vs %q", localFragment.OriginatingNode, a.nodeID) } - if a.localFragment.Clients == nil { + if localFragment.Clients == nil { t.Fatal("no local fragment entity slice") } - if a.localFragment.NonEntityTokens == nil { + if localFragment.NonEntityTokens == nil { t.Fatal("no local fragment token map") } - if len(a.localFragment.Clients) != 1 { - t.Fatalf("wrong number of entities %v", len(a.localFragment.Clients)) + if len(localFragment.Clients) != 1 { + t.Fatalf("wrong number of entities %v", len(localFragment.Clients)) } - er = a.localFragment.Clients[0] + er = localFragment.Clients[0] if er.ClientID != local_entity_id { t.Errorf("mimatched entity ID, %q vs %q", er.ClientID, local_entity_id) } @@ -192,17 +172,13 @@ func TestActivityLog_Creation_WrappingTokens(t *testing.T) { if a.logger == nil || a.view == nil { t.Fatal("activity log not initialized") } - a.fragmentLock.Lock() - if a.fragment != nil || a.currentGlobalFragment != nil { + if core.GetActiveGlobalFragment() != nil { t.Fatal("activity log already has fragment") } - a.fragmentLock.Unlock() - a.localFragmentLock.Lock() - if a.localFragment != nil { + if core.GetActiveLocalFragment() != nil { t.Fatal("activity log already has local fragment") } - a.localFragmentLock.Unlock() const namespace_id = "ns123" @@ -220,11 +196,9 @@ func TestActivityLog_Creation_WrappingTokens(t *testing.T) { t.Fatal(err) } - a.fragmentLock.Lock() - if a.fragment != nil || a.currentGlobalFragment != nil { + if core.GetActiveGlobalFragment() != nil { t.Fatal("fragment created") } - a.fragmentLock.Unlock() teNew := &logical.TokenEntry{ Path: "test", @@ -240,11 +214,9 @@ func TestActivityLog_Creation_WrappingTokens(t *testing.T) { t.Fatal(err) } - a.fragmentLock.Lock() - if a.fragment != nil || a.currentGlobalFragment != nil { + if core.GetActiveGlobalFragment() != nil { t.Fatal("fragment created") } - a.fragmentLock.Unlock() } func checkExpectedEntitiesInMap(t *testing.T, a *ActivityLog, entityIDs []string) { @@ -280,36 +252,15 @@ func TestActivityLog_UniqueEntities(t *testing.T) { a.AddEntityToFragment(id2, "root", t3.Unix()) a.AddEntityToFragment(id1, "root", t3.Unix()) - if a.fragment == nil || a.currentGlobalFragment == nil { - t.Fatal("no current fragment") + currentGlobalFragment := core.GetActiveGlobalFragment() + if currentGlobalFragment == nil { + t.Fatal("no current global fragment") } - - if len(a.fragment.Clients) != 2 { - t.Fatalf("number of entities is %v", len(a.fragment.Clients)) - } - if len(a.currentGlobalFragment.Clients) != 2 { - t.Fatalf("number of entities is %v", len(a.currentGlobalFragment.Clients)) + if len(currentGlobalFragment.Clients) != 2 { + t.Fatalf("number of entities is %v", len(currentGlobalFragment.Clients)) } - for i, e := range a.fragment.Clients { - expectedID := id1 - expectedTime := t1.Unix() - expectedNS := "root" - if i == 1 { - expectedID = id2 - expectedTime = t2.Unix() - } - if e.ClientID != expectedID { - t.Errorf("%v: expected %q, got %q", i, expectedID, e.ClientID) - } - if e.NamespaceID != expectedNS { - t.Errorf("%v: expected %q, got %q", i, expectedNS, e.NamespaceID) - } - if e.Timestamp != expectedTime { - t.Errorf("%v: expected %v, got %v", i, expectedTime, e.Timestamp) - } - } - for i, e := range a.currentGlobalFragment.Clients { + for i, e := range currentGlobalFragment.Clients { expectedID := id1 expectedTime := t1.Unix() expectedNS := "root" @@ -410,11 +361,11 @@ func TestActivityLog_SaveTokensToStorage(t *testing.T) { if err != nil { t.Fatalf("got error writing tokens to storage: %v", err) } - if a.fragment != nil || a.currentGlobalFragment != nil { + if core.GetActiveGlobalFragment() != nil { t.Errorf("fragment was not reset after write to storage") } - if a.localFragment != nil { + if core.GetActiveLocalFragment() != nil { t.Errorf("local fragment was not reset after write to storage") } @@ -446,11 +397,12 @@ func TestActivityLog_SaveTokensToStorage(t *testing.T) { if err != nil { t.Fatalf("got error writing tokens to storage: %v", err) } - if a.fragment != nil || a.currentGlobalFragment != nil { + + if core.GetActiveGlobalFragment() != nil { t.Errorf("fragment was not reset after write to storage") } - if a.localFragment != nil { + if core.GetActiveLocalFragment() != nil { t.Errorf("local fragment was not reset after write to storage") } @@ -492,7 +444,8 @@ func TestActivityLog_SaveTokensToStorageDoesNotUpdateTokenCount(t *testing.T) { a.SetStartTimestamp(time.Now().Unix()) // set a nonzero segment tokenPath := fmt.Sprintf("%sdirecttokens/%d/0", ActivityLogLocalPrefix, a.GetStartTimestamp()) - clientPath := fmt.Sprintf("sys/counters/activity/log/entity/%d/0", a.GetStartTimestamp()) + clientPath := fmt.Sprintf("sys/counters/activity/global/log/entity/%d/0", a.GetStartTimestamp()) + localPath := fmt.Sprintf("sys/counters/activity/local/log/entity/%d/0", a.GetStartTimestamp()) // Create some entries without entityIDs tokenEntryOne := logical.TokenEntry{NamespaceID: namespace.RootNamespaceID, Policies: []string{"hi"}} entityEntry := logical.TokenEntry{EntityID: "foo", NamespaceID: namespace.RootNamespaceID, Policies: []string{"hi"}} @@ -506,6 +459,9 @@ func TestActivityLog_SaveTokensToStorageDoesNotUpdateTokenCount(t *testing.T) { } } + // verify that the client got added to a local fragment + require.Len(t, core.GetActiveLocalFragment().Clients, 1) + idEntity, isTWE := entityEntry.CreateClientID() for i := 0; i < 2; i++ { err := a.HandleTokenUsage(ctx, &entityEntry, idEntity, isTWE) @@ -513,35 +469,53 @@ func TestActivityLog_SaveTokensToStorageDoesNotUpdateTokenCount(t *testing.T) { t.Fatal(err) } } + + // verify that the client got added to the global fragment + require.Len(t, core.GetActiveGlobalFragment().Clients, 1) + err := a.saveCurrentSegmentToStorage(ctx, false) if err != nil { t.Fatalf("got error writing TWEs to storage: %v", err) } // Assert that new elements have been written to the fragment - if a.fragment != nil || a.currentGlobalFragment != nil { + if core.GetActiveGlobalFragment() != nil { t.Errorf("fragment was not reset after write to storage") } - if a.localFragment != nil { + if core.GetActiveLocalFragment() != nil { t.Errorf("local fragment was not reset after write to storage") } // Assert that no tokens have been written to the fragment readSegmentFromStorageNil(t, core, tokenPath) + allClients := make([]*activity.EntityRecord, 0) e := readSegmentFromStorage(t, core, clientPath) out := &activity.EntityActivityLog{} err = proto.Unmarshal(e.Value, out) if err != nil { t.Fatalf("could not unmarshal protobuf: %v", err) } - if len(out.Clients) != 2 { - t.Fatalf("added 3 distinct TWEs and 2 distinct entity tokens that should all result in the same ID, got: %d", len(out.Clients)) + if len(out.Clients) != 1 { + t.Fatalf("added 2 distinct entity tokens that should all result in the same ID, got: %d", len(out.Clients)) } + allClients = append(allClients, out.Clients...) + + e = readSegmentFromStorage(t, core, localPath) + out = &activity.EntityActivityLog{} + err = proto.Unmarshal(e.Value, out) + if err != nil { + t.Fatalf("could not unmarshal protobuf: %v", err) + } + if len(out.Clients) != 1 { + t.Fatalf("added 3 distinct TWEs that should all result in the same ID, got: %d", len(out.Clients)) + } + allClients = append(allClients, out.Clients...) + nonEntityTokenFlag := false entityTokenFlag := false - for _, client := range out.Clients { + for _, client := range allClients { if client.NonEntity == true { nonEntityTokenFlag = true if client.ClientID != idNonEntity { @@ -578,7 +552,6 @@ func TestActivityLog_SaveEntitiesToStorage(t *testing.T) { now.Add(1 * time.Second).Unix(), now.Add(2 * time.Second).Unix(), } - path := fmt.Sprintf("%sentity/%d/0", ActivityLogPrefix, a.GetStartTimestamp()) globalPath := fmt.Sprintf("%sentity/%d/0", ActivityGlobalLogPrefix, a.GetStartTimestamp()) a.AddEntityToFragment(ids[0], "root", times[0]) @@ -587,14 +560,14 @@ func TestActivityLog_SaveEntitiesToStorage(t *testing.T) { if err != nil { t.Fatalf("got error writing entities to storage: %v", err) } - if a.fragment != nil || a.currentGlobalFragment != nil { + if core.GetActiveGlobalFragment() != nil { t.Errorf("fragment was not reset after write to storage") } - if a.localFragment != nil { + if core.GetActiveLocalFragment() != nil { t.Errorf("local fragment was not reset after write to storage") } - protoSegment := readSegmentFromStorage(t, core, path) + protoSegment := readSegmentFromStorage(t, core, globalPath) out := &activity.EntityActivityLog{} err = proto.Unmarshal(protoSegment.Value, out) if err != nil { @@ -609,14 +582,6 @@ func TestActivityLog_SaveEntitiesToStorage(t *testing.T) { t.Fatalf("got error writing segments to storage: %v", err) } - protoSegment = readSegmentFromStorage(t, core, path) - out = &activity.EntityActivityLog{} - err = proto.Unmarshal(protoSegment.Value, out) - if err != nil { - t.Fatalf("could not unmarshal protobuf: %v", err) - } - expectedEntityIDs(t, out, ids) - protoSegment = readSegmentFromStorage(t, core, globalPath) out = &activity.EntityActivityLog{} err = proto.Unmarshal(protoSegment.Value, out) @@ -686,7 +651,7 @@ func TestActivityLog_SaveEntitiesToStorageCommon(t *testing.T) { if err != nil { t.Fatalf("got error writing entities to storage: %v", err) } - if a.fragment != nil { + if core.GetActiveGlobalFragment() != nil || core.GetActiveLocalFragment() != nil { t.Errorf("fragment was not reset after write to storage") } @@ -775,8 +740,8 @@ func TestModifyResponseMonthsNilAppend(t *testing.T) { } // TestActivityLog_ReceivedFragment calls receivedFragment with a fragment and verifies it gets added to -// standbyFragmentsReceived and standbyGlobalFragmentsReceived. Send the same fragment again and then verify that it doesn't change the entity map but does -// get added to standbyFragmentsReceived and standbyGlobalFragmentsReceived. +// standbyGlobalFragmentsReceived. Send the same fragment again and then verify that it doesn't change the entity map but does +// get added to standbyGlobalFragmentsReceived. func TestActivityLog_ReceivedFragment(t *testing.T) { core, _, _ := TestCoreUnsealed(t) a := core.activityLog @@ -806,7 +771,7 @@ func TestActivityLog_ReceivedFragment(t *testing.T) { NonEntityTokens: make(map[string]uint64), } - if len(a.standbyFragmentsReceived) != 0 { + if len(a.standbyGlobalFragmentsReceived) != 0 { t.Fatalf("fragment already received") } @@ -814,10 +779,6 @@ func TestActivityLog_ReceivedFragment(t *testing.T) { checkExpectedEntitiesInMap(t, a, ids) - if len(a.standbyFragmentsReceived) != 1 { - t.Fatalf("fragment count is %v, expected 1", len(a.standbyFragmentsReceived)) - } - if len(a.standbyGlobalFragmentsReceived) != 1 { t.Fatalf("fragment count is %v, expected 1", len(a.standbyGlobalFragmentsReceived)) } @@ -827,9 +788,6 @@ func TestActivityLog_ReceivedFragment(t *testing.T) { checkExpectedEntitiesInMap(t, a, ids) - if len(a.standbyFragmentsReceived) != 2 { - t.Fatalf("fragment count is %v, expected 2", len(a.standbyFragmentsReceived)) - } if len(a.standbyGlobalFragmentsReceived) != 2 { t.Fatalf("fragment count is %v, expected 2", len(a.standbyGlobalFragmentsReceived)) } @@ -856,12 +814,17 @@ func TestActivityLog_availableLogs(t *testing.T) { // set up a few files in storage core, _, _ := TestCoreUnsealed(t) a := core.activityLog - paths := [...]string{"entity/1111/1", "entity/992/3"} + globalPaths := [...]string{"entity/1111/1", "entity/992/3", "entity/991/1"} + localPaths := [...]string{"entity/1111/1", "entity/992/3", "entity/990/1"} tokenPaths := [...]string{"directtokens/1111/1", "directtokens/1000000/1", "directtokens/992/1"} - expectedTimes := [...]time.Time{time.Unix(1000000, 0), time.Unix(1111, 0), time.Unix(992, 0)} + expectedTimes := [...]time.Time{time.Unix(1000000, 0), time.Unix(1111, 0), time.Unix(992, 0), time.Unix(991, 0), time.Unix(990, 0)} - for _, path := range paths { - WriteToStorage(t, core, ActivityLogPrefix+path, []byte("test")) + for _, path := range globalPaths { + WriteToStorage(t, core, ActivityGlobalLogPrefix+path, []byte("test")) + } + + for _, path := range localPaths { + WriteToStorage(t, core, ActivityLogLocalPrefix+path, []byte("test")) } for _, path := range tokenPaths { @@ -950,7 +913,7 @@ func TestActivityLog_createRegenerationIntentLog(t *testing.T) { } for _, subPath := range paths { - fullPath := ActivityLogPrefix + subPath + fullPath := ActivityGlobalLogPrefix + subPath WriteToStorage(t, core, fullPath, []byte("test")) deletePaths = append(deletePaths, fullPath) } @@ -999,9 +962,9 @@ func TestActivityLog_MultipleFragmentsAndSegments(t *testing.T) { a.SetStartTimestamp(time.Now().Unix()) // set a nonzero segment startTimestamp := a.GetStartTimestamp() - path0 := fmt.Sprintf("sys/counters/activity/log/entity/%d/0", startTimestamp) - path1 := fmt.Sprintf("sys/counters/activity/log/entity/%d/1", startTimestamp) - path2 := fmt.Sprintf("sys/counters/activity/log/entity/%d/2", startTimestamp) + path0 := fmt.Sprintf("sys/counters/activity/global/log/entity/%d/0", startTimestamp) + path1 := fmt.Sprintf("sys/counters/activity/global/log/entity/%d/1", startTimestamp) + path2 := fmt.Sprintf("sys/counters/activity/global/log/entity/%d/2", startTimestamp) tokenPath := fmt.Sprintf("sys/counters/activity/local/log/directtokens/%d/0", startTimestamp) genID := func(i int) string { @@ -1094,11 +1057,6 @@ func TestActivityLog_MultipleFragmentsAndSegments(t *testing.T) { t.Fatalf("got error writing entities to storage: %v", err) } - seqNum := a.GetEntitySequenceNumber() - if seqNum != 2 { - t.Fatalf("expected sequence number 2, got %v", seqNum) - } - protoSegment0 = readSegmentFromStorage(t, core, path0) err = proto.Unmarshal(protoSegment0.Value, &entityLog0) if err != nil { @@ -1305,12 +1263,8 @@ func TestActivityLog_parseSegmentNumberFromPath(t *testing.T) { func TestActivityLog_getLastEntitySegmentNumber(t *testing.T) { core, _, _ := TestCoreUnsealed(t) a := core.activityLog - paths := [...]string{"entity/992/0", "entity/1000/-1", "entity/1001/foo", "entity/1111/0", "entity/1111/1"} globalPaths := [...]string{"entity/992/0", "entity/1000/-1", "entity/1001/foo", "entity/1111/1"} localPaths := [...]string{"entity/992/0", "entity/1000/-1", "entity/1001/foo", "entity/1111/0", "entity/1111/1"} - for _, path := range paths { - WriteToStorage(t, core, ActivityLogPrefix+path, []byte("test")) - } for _, path := range globalPaths { WriteToStorage(t, core, ActivityGlobalLogPrefix+path, []byte("test")) } @@ -1320,42 +1274,36 @@ func TestActivityLog_getLastEntitySegmentNumber(t *testing.T) { testCases := []struct { input int64 - expectedVal uint64 expectedGlobalVal uint64 expectedLocalVal uint64 expectExists bool }{ { input: 992, - expectedVal: 0, expectedGlobalVal: 0, expectedLocalVal: 0, expectExists: true, }, { input: 1000, - expectedVal: 0, expectedGlobalVal: 0, expectedLocalVal: 0, expectExists: false, }, { input: 1001, - expectedVal: 0, expectedGlobalVal: 0, expectedLocalVal: 0, expectExists: false, }, { input: 1111, - expectedVal: 1, expectedGlobalVal: 1, expectedLocalVal: 1, expectExists: true, }, { input: 2222, - expectedVal: 0, expectedGlobalVal: 0, expectedLocalVal: 0, expectExists: false, @@ -1364,16 +1312,13 @@ func TestActivityLog_getLastEntitySegmentNumber(t *testing.T) { ctx := context.Background() for _, tc := range testCases { - result, localSegmentNumber, globalSegmentNumber, exists, err := a.getLastEntitySegmentNumber(ctx, time.Unix(tc.input, 0)) + localSegmentNumber, globalSegmentNumber, exists, err := a.getLastEntitySegmentNumber(ctx, time.Unix(tc.input, 0)) if err != nil { t.Fatalf("unexpected error for input %d: %v", tc.input, err) } if exists != tc.expectExists { t.Errorf("expected result exists: %t, got: %t for input: %d", tc.expectExists, exists, tc.input) } - if result != tc.expectedVal { - t.Errorf("expected: %d got: %d for input: %d", tc.expectedVal, result, tc.input) - } if globalSegmentNumber != tc.expectedGlobalVal { t.Errorf("expected: %d got: %d for input: %d", tc.expectedGlobalVal, globalSegmentNumber, tc.input) } @@ -1505,15 +1450,6 @@ func (a *ActivityLog) resetEntitiesInMemory(t *testing.T) { a.globalFragmentLock.Lock() defer a.globalFragmentLock.Unlock() - a.currentSegment = segmentInfo{ - startTimestamp: time.Time{}.Unix(), - currentClients: &activity.EntityActivityLog{ - Clients: make([]*activity.EntityRecord, 0), - }, - tokenCount: a.currentSegment.tokenCount, - clientSequenceNumber: 0, - } - a.currentGlobalSegment = segmentInfo{ startTimestamp: time.Time{}.Unix(), currentClients: &activity.EntityActivityLog{ @@ -1532,7 +1468,6 @@ func (a *ActivityLog) resetEntitiesInMemory(t *testing.T) { clientSequenceNumber: 0, } - a.partialMonthClientTracker = make(map[string]*activity.EntityRecord) a.partialMonthLocalClientTracker = make(map[string]*activity.EntityRecord) a.globalPartialMonthClientTracker = make(map[string]*activity.EntityRecord) } @@ -1549,7 +1484,6 @@ func TestActivityLog_loadCurrentClientSegment(t *testing.T) { CountByNamespaceID: tokenRecords, } a.l.Lock() - a.currentSegment.tokenCount = tokenCount a.currentLocalSegment.tokenCount = tokenCount a.l.Unlock() @@ -1610,7 +1544,6 @@ func TestActivityLog_loadCurrentClientSegment(t *testing.T) { if err != nil { t.Fatalf(err.Error()) } - WriteToStorage(t, core, ActivityLogPrefix+tc.path, data) WriteToStorage(t, core, ActivityGlobalLogPrefix+tc.path, data) WriteToStorage(t, core, ActivityLogLocalPrefix+tc.path, data) } @@ -1624,7 +1557,7 @@ func TestActivityLog_loadCurrentClientSegment(t *testing.T) { // loadCurrentClientSegment requires us to grab the fragment lock and the // activityLog lock, as per the comment in the loadCurrentClientSegment // function - err := a.loadCurrentClientSegment(ctx, time.Unix(tc.time, 0), tc.seqNum, tc.seqNum, tc.seqNum) + err := a.loadCurrentClientSegment(ctx, time.Unix(tc.time, 0), tc.seqNum, tc.seqNum) a.localFragmentLock.Unlock() a.globalFragmentLock.Unlock() a.fragmentLock.Unlock() @@ -1639,15 +1572,9 @@ func TestActivityLog_loadCurrentClientSegment(t *testing.T) { // verify accurate data in in-memory current segment require.Equal(t, tc.time, a.GetStartTimestamp()) - require.Equal(t, tc.seqNum, a.GetEntitySequenceNumber()) require.Equal(t, tc.seqNum, a.GetGlobalEntitySequenceNumber()) require.Equal(t, tc.seqNum, a.GetLocalEntitySequenceNumber()) - currentEntities := a.GetCurrentEntities() - if !entityRecordsEqual(t, currentEntities.Clients, tc.entities.Clients) { - t.Errorf("bad data loaded. expected: %v, got: %v for path %q", tc.entities.Clients, currentEntities, tc.path) - } - globalClients := core.GetActiveGlobalClientsList() if err := ActiveEntitiesEqual(globalClients, tc.entities.Clients); err != nil { t.Errorf("bad data loaded into active global entities. expected only set of EntityID from %v in %v for path %q: %v", tc.entities.Clients, globalClients, tc.path, err) @@ -1739,7 +1666,6 @@ func TestActivityLog_loadPriorEntitySegment(t *testing.T) { if err != nil { t.Fatalf(err.Error()) } - WriteToStorage(t, core, ActivityLogPrefix+tc.path, data) WriteToStorage(t, core, ActivityGlobalLogPrefix+tc.path, data) WriteToStorage(t, core, ActivityLogLocalPrefix+tc.path, data) } @@ -1748,20 +1674,22 @@ func TestActivityLog_loadPriorEntitySegment(t *testing.T) { for _, tc := range testCases { if tc.refresh { a.l.Lock() - a.fragmentLock.Lock() a.localFragmentLock.Lock() - a.partialMonthClientTracker = make(map[string]*activity.EntityRecord) a.partialMonthLocalClientTracker = make(map[string]*activity.EntityRecord) a.globalPartialMonthClientTracker = make(map[string]*activity.EntityRecord) - a.currentSegment.startTimestamp = tc.time a.currentGlobalSegment.startTimestamp = tc.time a.currentLocalSegment.startTimestamp = tc.time - a.fragmentLock.Unlock() a.localFragmentLock.Unlock() a.l.Unlock() } - err := a.loadPriorEntitySegment(ctx, time.Unix(tc.time, 0), tc.seqNum) + // load global segments + err := a.loadPriorEntitySegment(ctx, time.Unix(tc.time, 0), tc.seqNum, false) + if err != nil { + t.Fatalf("got error loading data for %q: %v", tc.path, err) + } + // load local segments + err = a.loadPriorEntitySegment(ctx, time.Unix(tc.time, 0), tc.seqNum, true) if err != nil { t.Fatalf("got error loading data for %q: %v", tc.path, err) } @@ -1937,14 +1865,12 @@ func setupActivityRecordsInStorage(t *testing.T, base time.Time, includeEntities } switch i { case 0: - WriteToStorage(t, core, ActivityLogPrefix+"entity/"+fmt.Sprint(monthsAgo.Unix())+"/0", entityData) WriteToStorage(t, core, ActivityGlobalLogPrefix+"entity/"+fmt.Sprint(monthsAgo.Unix())+"/0", entityData) case len(entityRecords) - 1: // local data WriteToStorage(t, core, ActivityLogLocalPrefix+"entity/"+fmt.Sprint(base.Unix())+"/"+strconv.Itoa(i-1), entityData) default: - WriteToStorage(t, core, ActivityLogPrefix+"entity/"+fmt.Sprint(base.Unix())+"/"+strconv.Itoa(i-1), entityData) WriteToStorage(t, core, ActivityGlobalLogPrefix+"entity/"+fmt.Sprint(base.Unix())+"/"+strconv.Itoa(i-1), entityData) } } @@ -1988,16 +1914,24 @@ func TestActivityLog_refreshFromStoredLog(t *testing.T) { } wg.Wait() + // active clients for the entire month expectedActive := &activity.EntityActivityLog{ Clients: expectedClientRecords[1:], } - expectedCurrent := &activity.EntityActivityLog{ - Clients: expectedClientRecords[len(expectedClientRecords)-2 : len(expectedClientRecords)-1], + expectedActiveGlobal := &activity.EntityActivityLog{ + Clients: expectedClientRecords[1 : len(expectedClientRecords)-1], } + + // local client is only added to the newest segment for the current month. This should also appear in the active clients for the entire month. expectedCurrentLocal := &activity.EntityActivityLog{ Clients: expectedClientRecords[len(expectedClientRecords)-1:], } + // global clients added to the newest local entity segment + expectedCurrent := &activity.EntityActivityLog{ + Clients: expectedClientRecords[len(expectedClientRecords)-2 : len(expectedClientRecords)-1], + } + currentEntities := a.GetCurrentGlobalEntities() if !entityRecordsEqual(t, currentEntities.Clients, expectedCurrent.Clients) { // we only expect the newest entity segment to be loaded (for the current month) @@ -2021,6 +1955,19 @@ func TestActivityLog_refreshFromStoredLog(t *testing.T) { // we expect activeClients to be loaded for the entire month t.Errorf("bad data loaded into active entities. expected only set of EntityID from %v in %v: %v", expectedActive.Clients, activeClients, err) } + + // verify active global clients list + activeGlobalClients := a.core.GetActiveGlobalClientsList() + if err := ActiveEntitiesEqual(activeGlobalClients, expectedActiveGlobal.Clients); err != nil { + // we expect activeClients to be loaded for the entire month + t.Errorf("bad data loaded into active global entities. expected only set of EntityID from %v in %v: %v", expectedActiveGlobal.Clients, activeGlobalClients, err) + } + // verify active local clients list + activeLocalClients := a.core.GetActiveLocalClientsList() + if err := ActiveEntitiesEqual(activeLocalClients, expectedCurrentLocal.Clients); err != nil { + // we expect activeClients to be loaded for the entire month + t.Errorf("bad data loaded into active local entities. expected only set of EntityID from %v in %v: %v", expectedCurrentLocal.Clients, activeLocalClients, err) + } } // TestActivityLog_refreshFromStoredLogWithBackgroundLoadingCancelled writes data from 3 months ago to this month. The @@ -2082,6 +2029,18 @@ func TestActivityLog_refreshFromStoredLogWithBackgroundLoadingCancelled(t *testi // we only expect activeClients to be loaded for the newest segment (for the current month) t.Error(err) } + + // verify if the right global clients are loaded for the newest segment (for the current month) + activeGlobalClients := a.core.GetActiveGlobalClientsList() + if err := ActiveEntitiesEqual(activeGlobalClients, expectedCurrent.Clients); err != nil { + t.Error(err) + } + + // the right local clients are loaded for the newest segment (for the current month) + activeLocalClients := a.core.GetActiveLocalClientsList() + if err := ActiveEntitiesEqual(activeLocalClients, currentLocalEntities.Clients); err != nil { + t.Error(err) + } } // TestActivityLog_refreshFromStoredLogContextCancelled writes data from 3 months ago to this month and calls @@ -2115,9 +2074,6 @@ func TestActivityLog_refreshFromStoredLogNoTokens(t *testing.T) { expectedActive := &activity.EntityActivityLog{ Clients: expectedClientRecords[1:], } - expectedCurrent := &activity.EntityActivityLog{ - Clients: expectedClientRecords[len(expectedClientRecords)-2 : len(expectedClientRecords)-1], - } expectedCurrentGlobal := &activity.EntityActivityLog{ Clients: expectedClientRecords[len(expectedClientRecords)-2 : len(expectedClientRecords)-1], } @@ -2125,12 +2081,6 @@ func TestActivityLog_refreshFromStoredLogNoTokens(t *testing.T) { Clients: expectedClientRecords[len(expectedClientRecords)-1:], } - currentEntities := a.GetCurrentEntities() - if !entityRecordsEqual(t, currentEntities.Clients, expectedCurrent.Clients) { - // we expect all segments for the current month to be loaded - t.Errorf("bad activity entity logs loaded. expected: %v got: %v", expectedCurrent, currentEntities) - } - currentGlobalEntities := a.GetCurrentGlobalEntities() if !entityRecordsEqual(t, currentGlobalEntities.Clients, expectedCurrentGlobal.Clients) { // we only expect the newest entity segment to be loaded (for the current month) @@ -2174,7 +2124,7 @@ func TestActivityLog_refreshFromStoredLogNoEntities(t *testing.T) { t.Errorf("bad activity token counts loaded. expected: %v got: %v", expectedTokenCounts, nsCount) } - currentEntities := a.GetCurrentEntities() + currentEntities := a.GetCurrentGlobalEntities() if len(currentEntities.Clients) > 0 { t.Errorf("expected no current entity segment to be loaded. got: %v", currentEntities) } @@ -2245,7 +2195,7 @@ func TestActivityLog_refreshFromStoredLogPreviousMonth(t *testing.T) { Clients: expectedClientRecords[len(expectedClientRecords)-2 : len(expectedClientRecords)-1], } - currentEntities := a.GetCurrentEntities() + currentEntities := a.GetCurrentGlobalEntities() if !entityRecordsEqual(t, currentEntities.Clients, expectedCurrent.Clients) { // we only expect the newest entity segment to be loaded (for the current month) t.Errorf("bad activity entity logs loaded. expected: %v got: %v", expectedCurrent, currentEntities) @@ -2343,16 +2293,7 @@ func TestActivityLog_DeleteWorker(t *testing.T) { "entity/1112/1", } for _, path := range paths { - WriteToStorage(t, core, ActivityLogPrefix+path, []byte("test")) - } - - localPaths := []string{ - "entity/1111/1", - "entity/1111/2", - "entity/1111/3", - "entity/1112/1", - } - for _, path := range localPaths { + WriteToStorage(t, core, ActivityGlobalLogPrefix+path, []byte("test")) WriteToStorage(t, core, ActivityLogLocalPrefix+path, []byte("test")) } @@ -2376,14 +2317,14 @@ func TestActivityLog_DeleteWorker(t *testing.T) { } // Check segments still present - readSegmentFromStorage(t, core, ActivityLogPrefix+"entity/1112/1") + readSegmentFromStorage(t, core, ActivityGlobalLogPrefix+"entity/1112/1") readSegmentFromStorage(t, core, ActivityLogLocalPrefix+"entity/1112/1") readSegmentFromStorage(t, core, ActivityLogLocalPrefix+"directtokens/1112/1") // Check other segments not present - expectMissingSegment(t, core, ActivityLogPrefix+"entity/1111/1") - expectMissingSegment(t, core, ActivityLogPrefix+"entity/1111/2") - expectMissingSegment(t, core, ActivityLogPrefix+"entity/1111/3") + expectMissingSegment(t, core, ActivityGlobalLogPrefix+"entity/1111/1") + expectMissingSegment(t, core, ActivityGlobalLogPrefix+"entity/1111/2") + expectMissingSegment(t, core, ActivityGlobalLogPrefix+"entity/1111/3") expectMissingSegment(t, core, ActivityLogLocalPrefix+"entity/1111/1") expectMissingSegment(t, core, ActivityLogLocalPrefix+"entity/1111/2") expectMissingSegment(t, core, ActivityLogLocalPrefix+"entity/1111/3") @@ -2473,7 +2414,7 @@ func TestActivityLog_EnableDisable(t *testing.T) { } // verify segment exists - path := fmt.Sprintf("%ventity/%v/0", ActivityLogPrefix, seg1) + path := fmt.Sprintf("%ventity/%v/0", ActivityGlobalLogPrefix, seg1) readSegmentFromStorage(t, core, path) // Add in-memory fragment @@ -2503,7 +2444,7 @@ func TestActivityLog_EnableDisable(t *testing.T) { } // Verify empty segments are present - path = fmt.Sprintf("%ventity/%v/0", ActivityLogPrefix, seg2) + path = fmt.Sprintf("%ventity/%v/0", ActivityGlobalLogPrefix, seg2) readSegmentFromStorage(t, core, path) path = fmt.Sprintf("%vdirecttokens/%v/0", ActivityLogLocalPrefix, seg2) @@ -2544,6 +2485,8 @@ func TestActivityLog_EndOfMonth(t *testing.T) { id2 := "22222222-2222-2222-2222-222222222222" id3 := "33333333-3333-3333-3333-333333333333" id4 := "44444444-4444-4444-4444-444444444444" + + // add global data a.AddEntityToFragment(id1, "root", time.Now().Unix()) // add local data @@ -2567,22 +2510,13 @@ func TestActivityLog_EndOfMonth(t *testing.T) { a.HandleEndOfMonth(ctx, month1) // Check segment is present, with 1 entity - path := fmt.Sprintf("%ventity/%v/0", ActivityLogPrefix, segment0) + path := fmt.Sprintf("%ventity/%v/0", ActivityGlobalLogPrefix, segment0) protoSegment := readSegmentFromStorage(t, core, path) out := &activity.EntityActivityLog{} err = proto.Unmarshal(protoSegment.Value, out) if err != nil { t.Fatal(err) } - expectedEntityIDs(t, out, []string{id1, id4}) - - path = fmt.Sprintf("%ventity/%v/0", ActivityGlobalLogPrefix, segment0) - protoSegment = readSegmentFromStorage(t, core, path) - out = &activity.EntityActivityLog{} - err = proto.Unmarshal(protoSegment.Value, out) - if err != nil { - t.Fatal(err) - } expectedEntityIDs(t, out, []string{id1}) path = fmt.Sprintf("%ventity/%v/0", ActivityLogLocalPrefix, segment0) @@ -2649,18 +2583,6 @@ func TestActivityLog_EndOfMonth(t *testing.T) { for i, tc := range testCases { t.Logf("checking segment %v timestamp %v", i, tc.SegmentTimestamp) - expectedAllEntities := make([]string, 0) - expectedAllEntities = append(expectedAllEntities, tc.ExpectedGlobalEntityIDs...) - expectedAllEntities = append(expectedAllEntities, tc.ExpectedLocalEntityIDs...) - path := fmt.Sprintf("%ventity/%v/0", ActivityLogPrefix, tc.SegmentTimestamp) - protoSegment := readSegmentFromStorage(t, core, path) - out := &activity.EntityActivityLog{} - err = proto.Unmarshal(protoSegment.Value, out) - if err != nil { - t.Fatalf("could not unmarshal protobuf: %v", err) - } - expectedEntityIDs(t, out, expectedAllEntities) - // Check for global entities at global storage path path = fmt.Sprintf("%ventity/%v/0", ActivityGlobalLogPrefix, tc.SegmentTimestamp) protoSegment = readSegmentFromStorage(t, core, path) @@ -2844,7 +2766,7 @@ func TestActivityLog_CalculatePrecomputedQueriesWithMixedTWEs(t *testing.T) { if err != nil { t.Fatal(err) } - path := fmt.Sprintf("%ventity/%v/%v", ActivityLogPrefix, segment.StartTime, segment.Segment) + path := fmt.Sprintf("%ventity/%v/%v", ActivityGlobalLogPrefix, segment.StartTime, segment.Segment) WriteToStorage(t, core, path, data) } expectedCounts := []struct { @@ -3125,10 +3047,10 @@ func TestActivityLog_SaveAfterDisable(t *testing.T) { t.Fatal(err) } - path := ActivityLogPrefix + "entity/0/0" + path := ActivityGlobalLogPrefix + "entity/0/0" expectMissingSegment(t, core, path) - path = fmt.Sprintf("%ventity/%v/0", ActivityLogPrefix, startTimestamp) + path = fmt.Sprintf("%ventity/%v/0", ActivityGlobalLogPrefix, startTimestamp) expectMissingSegment(t, core, path) } @@ -3229,7 +3151,7 @@ func TestActivityLog_Precompute(t *testing.T) { if err != nil { t.Fatal(err) } - path := fmt.Sprintf("%ventity/%v/%v", ActivityLogPrefix, segment.StartTime, segment.Segment) + path := fmt.Sprintf("%ventity/%v/%v", ActivityGlobalLogPrefix, segment.StartTime, segment.Segment) WriteToStorage(t, core, path, data) } @@ -3540,7 +3462,7 @@ func TestActivityLog_Precompute_SkipMonth(t *testing.T) { if err != nil { t.Fatal(err) } - path := fmt.Sprintf("%ventity/%v/%v", ActivityLogPrefix, segment.StartTime, segment.Segment) + path := fmt.Sprintf("%ventity/%v/%v", ActivityGlobalLogPrefix, segment.StartTime, segment.Segment) WriteToStorage(t, core, path, data) } @@ -3757,7 +3679,7 @@ func TestActivityLog_PrecomputeNonEntityTokensWithID(t *testing.T) { if err != nil { t.Fatal(err) } - path := fmt.Sprintf("%ventity/%v/%v", ActivityLogPrefix, segment.StartTime, segment.Segment) + path := fmt.Sprintf("%ventity/%v/%v", ActivityGlobalLogPrefix, segment.StartTime, segment.Segment) WriteToStorage(t, core, path, data) } @@ -4146,7 +4068,7 @@ func TestActivityLog_Deletion(t *testing.T) { for i, start := range times { // no entities in some months, just for fun for j := 0; j < (i+3)%5; j++ { - entityPath := fmt.Sprintf("%ventity/%v/%v", ActivityLogPrefix, start.Unix(), j) + entityPath := fmt.Sprintf("%ventity/%v/%v", ActivityGlobalLogPrefix, start.Unix(), j) paths[i] = append(paths[i], entityPath) WriteToStorage(t, core, entityPath, []byte("test")) } @@ -4522,7 +4444,7 @@ func TestActivityLog_partialMonthClientCountWithMultipleMountPaths(t *testing.T) if err != nil { t.Fatalf(err.Error()) } - storagePath := fmt.Sprintf("%sentity/%d/%d", ActivityLogPrefix, timeutil.StartOfMonth(now).Unix(), i) + storagePath := fmt.Sprintf("%sentity/%d/%d", ActivityGlobalLogPrefix, timeutil.StartOfMonth(now).Unix(), i) WriteToStorage(t, core, storagePath, entityData) } @@ -5162,7 +5084,6 @@ func TestAddActivityToFragment(t *testing.T) { a := core.activityLog a.SetEnable(true) - require.Nil(t, a.fragment) require.Nil(t, a.localFragment) require.Nil(t, a.currentGlobalFragment) @@ -5254,10 +5175,6 @@ func TestAddActivityToFragment(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { var mountAccessor string - a.fragmentLock.RLock() - numClientsBefore := len(a.fragment.Clients) - a.fragmentLock.RUnlock() - a.globalFragmentLock.RLock() globalClientsBefore := len(a.currentGlobalFragment.Clients) a.globalFragmentLock.RUnlock() @@ -5281,9 +5198,6 @@ func TestAddActivityToFragment(t *testing.T) { a.AddActivityToFragment(tc.id, ns, 0, tc.activityType, mount) } - a.fragmentLock.RLock() - defer a.fragmentLock.RUnlock() - numClientsAfter := len(a.fragment.Clients) a.globalFragmentLock.RLock() defer a.globalFragmentLock.RUnlock() globalClientsAfter := len(a.currentGlobalFragment.Clients) @@ -5312,24 +5226,6 @@ func TestAddActivityToFragment(t *testing.T) { } } - // for now local clients are added to both regular fragment and local fragment. - // this will be modified in ticket vault-31234 - if tc.isAdded { - require.Equal(t, numClientsBefore+1, numClientsAfter) - } else { - require.Equal(t, numClientsBefore, numClientsAfter) - } - - require.Contains(t, a.partialMonthClientTracker, tc.expectedID) - require.True(t, proto.Equal(&activity.EntityRecord{ - ClientID: tc.expectedID, - NamespaceID: ns, - Timestamp: 0, - NonEntity: tc.isNonEntity, - MountAccessor: mountAccessor, - ClientType: tc.activityType, - }, a.partialMonthClientTracker[tc.expectedID])) - if tc.isLocal { require.Contains(t, a.partialMonthLocalClientTracker, tc.expectedID) require.True(t, proto.Equal(&activity.EntityRecord{ @@ -5371,7 +5267,6 @@ func TestGetAllPartialMonthClients(t *testing.T) { a := core.activityLog a.SetEnable(true) - require.Nil(t, a.fragment) require.Nil(t, a.localFragment) require.Nil(t, a.currentGlobalFragment) @@ -5385,7 +5280,6 @@ func TestGetAllPartialMonthClients(t *testing.T) { a.AddActivityToFragment(clientID, ns, 0, entityActivityType, mount) require.NotNil(t, a.localFragment) - require.NotNil(t, a.fragment) require.NotNil(t, a.currentGlobalFragment) // create a local mount accessor @@ -5783,37 +5677,6 @@ func TestCreateSegment_StoreSegment(t *testing.T) { global: true, forceStore: true, }, - - { - testName: "[non-global] max segment size", - numClients: ActivitySegmentClientCapacity, - maxClientsPerFragment: ActivitySegmentClientCapacity, - global: false, - }, - { - testName: "[non-global] max segment size, multiple fragments", - numClients: ActivitySegmentClientCapacity, - maxClientsPerFragment: ActivitySegmentClientCapacity - 1, - global: false, - }, - { - testName: "[non-global] roll over", - numClients: ActivitySegmentClientCapacity + 2, - maxClientsPerFragment: ActivitySegmentClientCapacity, - global: false, - }, - { - testName: "[non-global] max segment size, rollover multiple fragments", - numClients: ActivitySegmentClientCapacity * 2, - maxClientsPerFragment: ActivitySegmentClientCapacity - 1, - global: false, - }, - { - testName: "[non-global] max client size, drop clients", - numClients: ActivitySegmentClientCapacity*2 + 1, - maxClientsPerFragment: ActivitySegmentClientCapacity, - global: false, - }, { testName: "[local] max client size, drop clients", numClients: ActivitySegmentClientCapacity*2 + 1, @@ -5910,10 +5773,7 @@ func TestCreateSegment_StoreSegment(t *testing.T) { segment := &a.currentGlobalSegment if !test.global { - segment = &a.currentSegment - if test.pathPrefix == activityLocalPathPrefix { - segment = &a.currentLocalSegment - } + segment = &a.currentLocalSegment } // Create segments and write to storage @@ -5932,24 +5792,13 @@ func TestCreateSegment_StoreSegment(t *testing.T) { clientTotal += len(entity.GetClients()) } } else { - if test.pathPrefix == activityLocalPathPrefix { - for { - entity, err := reader.ReadLocalEntity(ctx) - if errors.Is(err, io.EOF) { - break - } - require.NoError(t, err) - clientTotal += len(entity.GetClients()) - } - } else { - for { - entity, err := reader.ReadEntity(ctx) - if errors.Is(err, io.EOF) { - break - } - require.NoError(t, err) - clientTotal += len(entity.GetClients()) + for { + entity, err := reader.ReadLocalEntity(ctx) + if errors.Is(err, io.EOF) { + break } + require.NoError(t, err) + clientTotal += len(entity.GetClients()) } } diff --git a/vault/activity_log_testing_util.go b/vault/activity_log_testing_util.go index f9bb25ba142e..d0fd4b7b35ae 100644 --- a/vault/activity_log_testing_util.go +++ b/vault/activity_log_testing_util.go @@ -36,7 +36,7 @@ func (c *Core) InjectActivityLogDataThisMonth(t *testing.T) map[string]*activity Timestamp: c.activityLog.clock.Now().Unix(), NonEntity: i%2 == 0, } - c.activityLog.partialMonthClientTracker[er.ClientID] = er + c.activityLog.globalPartialMonthClientTracker[er.ClientID] = er } if constants.IsEnterprise { @@ -49,12 +49,12 @@ func (c *Core) InjectActivityLogDataThisMonth(t *testing.T) map[string]*activity Timestamp: c.activityLog.clock.Now().Unix(), NonEntity: i%2 == 0, } - c.activityLog.partialMonthClientTracker[er.ClientID] = er + c.activityLog.globalPartialMonthClientTracker[er.ClientID] = er } } } - return c.activityLog.partialMonthClientTracker + return c.activityLog.globalPartialMonthClientTracker } // GetActiveClients returns the in-memory globalPartialMonthClientTracker and partialMonthLocalClientTracker from an @@ -93,6 +93,7 @@ func (c *Core) GetActiveClientsList() []*activity.EntityRecord { return out } +// GetActiveLocalClientsList returns the active clients from globalPartialMonthClientTracker in activity log func (c *Core) GetActiveGlobalClientsList() []*activity.EntityRecord { out := []*activity.EntityRecord{} c.activityLog.globalFragmentLock.RLock() @@ -104,6 +105,7 @@ func (c *Core) GetActiveGlobalClientsList() []*activity.EntityRecord { return out } +// GetActiveLocalClientsList returns the active clients from partialMonthLocalClientTracker in activity log func (c *Core) GetActiveLocalClientsList() []*activity.EntityRecord { out := []*activity.EntityRecord{} c.activityLog.localFragmentLock.RLock() @@ -115,21 +117,14 @@ func (c *Core) GetActiveLocalClientsList() []*activity.EntityRecord { return out } -// GetCurrentEntities returns the current entity activity log -func (a *ActivityLog) GetCurrentEntities() *activity.EntityActivityLog { - a.l.RLock() - defer a.l.RUnlock() - return a.currentSegment.currentClients -} - -// GetCurrentGlobalEntities returns the current global entity activity log +// GetCurrentGlobalEntities returns the current clients from currentGlobalSegment in activity log func (a *ActivityLog) GetCurrentGlobalEntities() *activity.EntityActivityLog { a.l.RLock() defer a.l.RUnlock() return a.currentGlobalSegment.currentClients } -// GetCurrentLocalEntities returns the current local entity activity log +// GetCurrentLocalEntities returns the current clients from currentLocalSegment in activity log func (a *ActivityLog) GetCurrentLocalEntities() *activity.EntityActivityLog { a.l.RLock() defer a.l.RUnlock() @@ -169,8 +164,11 @@ func (a *ActivityLog) SetStandbyEnable(ctx context.Context, enabled bool) { // NOTE: AddTokenToFragment is deprecated and can no longer be used, except for // testing backward compatibility. Please use AddClientToFragment instead. func (a *ActivityLog) AddTokenToFragment(namespaceID string) { - a.fragmentLock.Lock() - defer a.fragmentLock.Unlock() + a.globalFragmentLock.Lock() + defer a.globalFragmentLock.Unlock() + + a.localFragmentLock.Lock() + defer a.localFragmentLock.Unlock() if !a.enabled { return @@ -178,7 +176,7 @@ func (a *ActivityLog) AddTokenToFragment(namespaceID string) { a.createCurrentFragment() - a.fragment.NonEntityTokens[namespaceID] += 1 + a.localFragment.NonEntityTokens[namespaceID] += 1 } func RandStringBytes(n int) string { @@ -199,20 +197,29 @@ func (a *ActivityLog) ExpectCurrentSegmentRefreshed(t *testing.T, expectedStart defer a.l.RUnlock() a.fragmentLock.RLock() defer a.fragmentLock.RUnlock() - if a.currentSegment.currentClients == nil { + if a.currentGlobalSegment.currentClients == nil { t.Fatalf("expected non-nil currentSegment.currentClients") } - if a.currentSegment.currentClients.Clients == nil { + if a.currentGlobalSegment.currentClients.Clients == nil { t.Errorf("expected non-nil currentSegment.currentClients.Entities") } - if a.currentSegment.tokenCount == nil { + if a.currentGlobalSegment.tokenCount == nil { t.Fatalf("expected non-nil currentSegment.tokenCount") } - if a.currentSegment.tokenCount.CountByNamespaceID == nil { + if a.currentGlobalSegment.tokenCount.CountByNamespaceID == nil { t.Errorf("expected non-nil currentSegment.tokenCount.CountByNamespaceID") } - if a.partialMonthClientTracker == nil { - t.Errorf("expected non-nil partialMonthClientTracker") + if a.currentLocalSegment.currentClients == nil { + t.Fatalf("expected non-nil currentSegment.currentClients") + } + if a.currentLocalSegment.currentClients.Clients == nil { + t.Errorf("expected non-nil currentSegment.currentClients.Entities") + } + if a.currentLocalSegment.tokenCount == nil { + t.Fatalf("expected non-nil currentSegment.tokenCount") + } + if a.currentLocalSegment.tokenCount.CountByNamespaceID == nil { + t.Errorf("expected non-nil currentSegment.tokenCount.CountByNamespaceID") } if a.partialMonthLocalClientTracker == nil { t.Errorf("expected non-nil partialMonthLocalClientTracker") @@ -220,14 +227,14 @@ func (a *ActivityLog) ExpectCurrentSegmentRefreshed(t *testing.T, expectedStart if a.globalPartialMonthClientTracker == nil { t.Errorf("expected non-nil globalPartialMonthClientTracker") } - if len(a.currentSegment.currentClients.Clients) > 0 { - t.Errorf("expected no current entity segment to be loaded. got: %v", a.currentSegment.currentClients) + if len(a.currentGlobalSegment.currentClients.Clients) > 0 { + t.Errorf("expected no current entity segment to be loaded. got: %v", a.currentGlobalSegment.currentClients) } - if len(a.currentSegment.tokenCount.CountByNamespaceID) > 0 { - t.Errorf("expected no token counts to be loaded. got: %v", a.currentSegment.tokenCount.CountByNamespaceID) + if len(a.currentLocalSegment.currentClients.Clients) > 0 { + t.Errorf("expected no current entity segment to be loaded. got: %v", a.currentLocalSegment.currentClients) } - if len(a.partialMonthClientTracker) > 0 { - t.Errorf("expected no active entity segment to be loaded. got: %v", a.partialMonthClientTracker) + if len(a.currentLocalSegment.tokenCount.CountByNamespaceID) > 0 { + t.Errorf("expected no token counts to be loaded. got: %v", a.currentLocalSegment.tokenCount.CountByNamespaceID) } if len(a.partialMonthLocalClientTracker) > 0 { t.Errorf("expected no active entity segment to be loaded. got: %v", a.partialMonthLocalClientTracker) @@ -237,17 +244,12 @@ func (a *ActivityLog) ExpectCurrentSegmentRefreshed(t *testing.T, expectedStart } if verifyTimeNotZero { - if a.currentSegment.startTimestamp == 0 { - t.Error("bad start timestamp. expected no reset but timestamp was reset") - } if a.currentGlobalSegment.startTimestamp == 0 { t.Error("bad start timestamp. expected no reset but timestamp was reset") } if a.currentLocalSegment.startTimestamp == 0 { t.Error("bad start timestamp. expected no reset but timestamp was reset") } - } else if a.currentSegment.startTimestamp != expectedStart { - t.Errorf("bad start timestamp. expected: %v got: %v", expectedStart, a.currentSegment.startTimestamp) } else if a.currentGlobalSegment.startTimestamp != expectedStart { t.Errorf("bad start timestamp. expected: %v got: %v", expectedStart, a.currentGlobalSegment.startTimestamp) } else if a.currentLocalSegment.startTimestamp != expectedStart { @@ -270,9 +272,7 @@ func ActiveEntitiesEqual(active []*activity.EntityRecord, test []*activity.Entit func (a *ActivityLog) GetStartTimestamp() int64 { a.l.RLock() defer a.l.RUnlock() - // TODO: We will substitute a.currentSegment with a.currentLocalSegment when we remove - // a.currentSegment from the code - if a.currentGlobalSegment.startTimestamp != a.currentSegment.startTimestamp { + if a.currentGlobalSegment.startTimestamp != a.currentLocalSegment.startTimestamp { return -1 } return a.currentGlobalSegment.startTimestamp @@ -282,7 +282,6 @@ func (a *ActivityLog) GetStartTimestamp() int64 { func (a *ActivityLog) SetStartTimestamp(timestamp int64) { a.l.Lock() defer a.l.Unlock() - a.currentSegment.startTimestamp = timestamp a.currentGlobalSegment.startTimestamp = timestamp a.currentLocalSegment.startTimestamp = timestamp } @@ -294,13 +293,6 @@ func (a *ActivityLog) GetStoredTokenCountByNamespaceID() map[string]uint64 { return a.currentLocalSegment.tokenCount.CountByNamespaceID } -// GetEntitySequenceNumber returns the current entity sequence number -func (a *ActivityLog) GetEntitySequenceNumber() uint64 { - a.l.RLock() - defer a.l.RUnlock() - return a.currentSegment.clientSequenceNumber -} - // GetGlobalEntitySequenceNumber returns the current entity sequence number func (a *ActivityLog) GetGlobalEntitySequenceNumber() uint64 { a.l.RLock() @@ -355,12 +347,6 @@ func (c *Core) GetActiveLocalFragment() *activity.LogFragment { return c.activityLog.localFragment } -func (c *Core) GetActiveFragment() *activity.LogFragment { - c.activityLog.fragmentLock.RLock() - defer c.activityLog.fragmentLock.RUnlock() - return c.activityLog.fragment -} - // StoreCurrentSegment is a test only method to create and store // segments from fragments. This allows createCurrentSegmentFromFragments to remain // private diff --git a/vault/activity_log_util_common.go b/vault/activity_log_util_common.go index c019d03a4739..f3cd616ed99a 100644 --- a/vault/activity_log_util_common.go +++ b/vault/activity_log_util_common.go @@ -425,7 +425,6 @@ type singleTypeSegmentReader struct { } type segmentReader struct { tokens *singleTypeSegmentReader - entities *singleTypeSegmentReader globalEntities *singleTypeSegmentReader localEntities *singleTypeSegmentReader } @@ -433,16 +432,11 @@ type segmentReader struct { // SegmentReader is an interface that provides methods to read tokens and entities in order type SegmentReader interface { ReadToken(ctx context.Context) (*activity.TokenCount, error) - ReadEntity(ctx context.Context) (*activity.EntityActivityLog, error) ReadGlobalEntity(ctx context.Context) (*activity.EntityActivityLog, error) ReadLocalEntity(ctx context.Context) (*activity.EntityActivityLog, error) } func (a *ActivityLog) NewSegmentFileReader(ctx context.Context, startTime time.Time) (SegmentReader, error) { - entities, err := a.newSingleTypeSegmentReader(ctx, startTime, activityEntityBasePath) - if err != nil { - return nil, err - } globalEntities, err := a.newSingleTypeSegmentReader(ctx, startTime, activityGlobalPathPrefix+activityEntityBasePath) if err != nil { return nil, err @@ -455,7 +449,7 @@ func (a *ActivityLog) NewSegmentFileReader(ctx context.Context, startTime time.T if err != nil { return nil, err } - return &segmentReader{entities: entities, globalEntities: globalEntities, localEntities: localEntities, tokens: tokens}, nil + return &segmentReader{globalEntities: globalEntities, localEntities: localEntities, tokens: tokens}, nil } func (a *ActivityLog) newSingleTypeSegmentReader(ctx context.Context, startTime time.Time, prefix string) (*singleTypeSegmentReader, error) { @@ -510,17 +504,6 @@ func (e *segmentReader) ReadToken(ctx context.Context) (*activity.TokenCount, er return out, nil } -// ReadEntity reads an entity from the segment -// If there is none available, then the error will be io.EOF -func (e *segmentReader) ReadEntity(ctx context.Context) (*activity.EntityActivityLog, error) { - out := &activity.EntityActivityLog{} - err := e.entities.nextValue(ctx, out) - if err != nil { - return nil, err - } - return out, nil -} - // ReadGlobalEntity reads a global entity from the global segment // If there is none available, then the error will be io.EOF func (e *segmentReader) ReadGlobalEntity(ctx context.Context) (*activity.EntityActivityLog, error) { diff --git a/vault/activity_log_util_common_test.go b/vault/activity_log_util_common_test.go index 7201cdc651f9..f84775da3fc2 100644 --- a/vault/activity_log_util_common_test.go +++ b/vault/activity_log_util_common_test.go @@ -1006,14 +1006,6 @@ func writeLocalEntitySegment(t *testing.T, core *Core, ts time.Time, index int, WriteToStorage(t, core, makeSegmentPath(t, activityLocalPathPrefix+activityEntityBasePath, ts, index), protoItem) } -// writeEntitySegment writes a single segment file with the given time and index for an entity -func writeEntitySegment(t *testing.T, core *Core, ts time.Time, index int, item *activity.EntityActivityLog) { - t.Helper() - protoItem, err := proto.Marshal(item) - require.NoError(t, err) - WriteToStorage(t, core, makeSegmentPath(t, activityEntityBasePath, ts, index), protoItem) -} - // writeTokenSegment writes a single segment file with the given time and index for a token func writeTokenSegment(t *testing.T, core *Core, ts time.Time, index int, item *activity.TokenCount) { t.Helper() @@ -1037,7 +1029,6 @@ func TestSegmentFileReader_BadData(t *testing.T) { // write bad data that won't be able to be unmarshaled at index 0 WriteToStorage(t, core, makeSegmentPath(t, activityTokenLocalBasePath, now, 0), []byte("fake data")) - WriteToStorage(t, core, makeSegmentPath(t, activityEntityBasePath, now, 0), []byte("fake data")) WriteToStorage(t, core, makeSegmentPath(t, activityGlobalPathPrefix+activityEntityBasePath, now, 0), []byte("fake data")) WriteToStorage(t, core, makeSegmentPath(t, activityLocalPathPrefix+activityEntityBasePath, now, 0), []byte("fake data")) @@ -1047,8 +1038,6 @@ func TestSegmentFileReader_BadData(t *testing.T) { ClientID: "id", }, }} - writeEntitySegment(t, core, now, 1, entity) - // write global data at index 1 writeGlobalEntitySegment(t, core, now, 1, entity) @@ -1063,25 +1052,19 @@ func TestSegmentFileReader_BadData(t *testing.T) { reader, err := core.activityLog.NewSegmentFileReader(context.Background(), now) require.NoError(t, err) - // first the bad entity is read, which returns an error - _, err = reader.ReadEntity(context.Background()) - require.Error(t, err) - // then, the reader can read the good entity at index 1 - gotEntity, err := reader.ReadEntity(context.Background()) - require.True(t, proto.Equal(gotEntity, entity)) - require.Nil(t, err) - // first the bad global entity is read, which returns an error _, err = reader.ReadGlobalEntity(context.Background()) require.Error(t, err) + // then, the reader can read the good entity at index 1 - gotEntity, err = reader.ReadGlobalEntity(context.Background()) + gotEntity, err := reader.ReadGlobalEntity(context.Background()) require.True(t, proto.Equal(gotEntity, entity)) require.Nil(t, err) // first the bad local entity is read, which returns an error _, err = reader.ReadLocalEntity(context.Background()) require.Error(t, err) + // then, the reader can read the good entity at index 1 gotEntity, err = reader.ReadLocalEntity(context.Background()) require.True(t, proto.Equal(gotEntity, entity)) @@ -1090,6 +1073,7 @@ func TestSegmentFileReader_BadData(t *testing.T) { // the bad token causes an error _, err = reader.ReadToken(context.Background()) require.Error(t, err) + // but the good token is able to be read gotToken, err := reader.ReadToken(context.Background()) require.True(t, proto.Equal(gotToken, token)) @@ -1104,9 +1088,7 @@ func TestSegmentFileReader_MissingData(t *testing.T) { // write entities and tokens at indexes 0, 1, 2 for i := 0; i < 3; i++ { WriteToStorage(t, core, makeSegmentPath(t, activityTokenLocalBasePath, now, i), []byte("fake data")) - WriteToStorage(t, core, makeSegmentPath(t, activityEntityBasePath, now, i), []byte("fake data")) WriteToStorage(t, core, makeSegmentPath(t, activityGlobalPathPrefix+activityEntityBasePath, now, i), []byte("fake data")) - } // write entity at index 3 entity := &activity.EntityActivityLog{Clients: []*activity.EntityRecord{ @@ -1114,7 +1096,6 @@ func TestSegmentFileReader_MissingData(t *testing.T) { ClientID: "id", }, }} - writeEntitySegment(t, core, now, 3, entity) // write global entity at index 3 writeGlobalEntitySegment(t, core, now, 3, entity) @@ -1133,25 +1114,18 @@ func TestSegmentFileReader_MissingData(t *testing.T) { // delete the indexes 0, 1, 2 for i := 0; i < 3; i++ { require.NoError(t, core.barrier.Delete(context.Background(), makeSegmentPath(t, activityTokenLocalBasePath, now, i))) - require.NoError(t, core.barrier.Delete(context.Background(), makeSegmentPath(t, activityEntityBasePath, now, i))) require.NoError(t, core.barrier.Delete(context.Background(), makeSegmentPath(t, activityGlobalPathPrefix+activityEntityBasePath, now, i))) require.NoError(t, core.barrier.Delete(context.Background(), makeSegmentPath(t, activityLocalPathPrefix+activityEntityBasePath, now, i))) } // we expect the reader to only return the data at index 3, and then be done - gotEntity, err := reader.ReadEntity(context.Background()) - require.NoError(t, err) - require.True(t, proto.Equal(gotEntity, entity)) - _, err = reader.ReadEntity(context.Background()) - require.Equal(t, err, io.EOF) - gotToken, err := reader.ReadToken(context.Background()) require.NoError(t, err) require.True(t, proto.Equal(gotToken, token)) _, err = reader.ReadToken(context.Background()) require.Equal(t, err, io.EOF) - gotEntity, err = reader.ReadGlobalEntity(context.Background()) + gotEntity, err := reader.ReadGlobalEntity(context.Background()) require.NoError(t, err) require.True(t, proto.Equal(gotEntity, entity)) _, err = reader.ReadGlobalEntity(context.Background()) @@ -1170,7 +1144,7 @@ func TestSegmentFileReader_NoData(t *testing.T) { now := time.Now() reader, err := core.activityLog.NewSegmentFileReader(context.Background(), now) require.NoError(t, err) - entity, err := reader.ReadEntity(context.Background()) + entity, err := reader.ReadGlobalEntity(context.Background()) require.Nil(t, entity) require.Equal(t, err, io.EOF) token, err := reader.ReadToken(context.Background()) @@ -1196,7 +1170,8 @@ func TestSegmentFileReader(t *testing.T) { token := &activity.TokenCount{CountByNamespaceID: map[string]uint64{ fmt.Sprintf("ns-%d", i): uint64(i), }} - writeEntitySegment(t, core, now, i, entity) + writeGlobalEntitySegment(t, core, now, i, entity) + writeLocalEntitySegment(t, core, now, i, entity) writeTokenSegment(t, core, now, i, token) entities = append(entities, entity) tokens = append(tokens, token) @@ -1205,13 +1180,20 @@ func TestSegmentFileReader(t *testing.T) { reader, err := core.activityLog.NewSegmentFileReader(context.Background(), now) require.NoError(t, err) - gotEntities := make([]*activity.EntityActivityLog, 0, 3) + gotGlobalEntities := make([]*activity.EntityActivityLog, 0, 3) + gotLocalEntities := make([]*activity.EntityActivityLog, 0, 3) gotTokens := make([]*activity.TokenCount, 0, 3) - // read the entities from the reader - for entity, err := reader.ReadEntity(context.Background()); !errors.Is(err, io.EOF); entity, err = reader.ReadEntity(context.Background()) { + // read the global entities from the reader + for entity, err := reader.ReadGlobalEntity(context.Background()); !errors.Is(err, io.EOF); entity, err = reader.ReadGlobalEntity(context.Background()) { + require.NoError(t, err) + gotGlobalEntities = append(gotGlobalEntities, entity) + } + + // read the local entities from the reader + for entity, err := reader.ReadLocalEntity(context.Background()); !errors.Is(err, io.EOF); entity, err = reader.ReadLocalEntity(context.Background()) { require.NoError(t, err) - gotEntities = append(gotEntities, entity) + gotLocalEntities = append(gotLocalEntities, entity) } // read the tokens from the reader @@ -1219,13 +1201,15 @@ func TestSegmentFileReader(t *testing.T) { require.NoError(t, err) gotTokens = append(gotTokens, token) } - require.Len(t, gotEntities, 3) + require.Len(t, gotGlobalEntities, 3) + require.Len(t, gotLocalEntities, 3) require.Len(t, gotTokens, 3) // verify that the entities and tokens we got from the reader are correct // we can't use require.Equals() here because there are protobuf differences in unexported fields for i := 0; i < 3; i++ { - require.True(t, proto.Equal(gotEntities[i], entities[i])) + require.True(t, proto.Equal(gotGlobalEntities[i], entities[i])) + require.True(t, proto.Equal(gotLocalEntities[i], entities[i])) require.True(t, proto.Equal(gotTokens[i], tokens[i])) } } diff --git a/vault/external_tests/activity_testonly/acme_regeneration_test.go b/vault/external_tests/activity_testonly/acme_regeneration_test.go index c663b174b84e..5d70dc0c21c5 100644 --- a/vault/external_tests/activity_testonly/acme_regeneration_test.go +++ b/vault/external_tests/activity_testonly/acme_regeneration_test.go @@ -54,7 +54,7 @@ func TestACMERegeneration_RegenerateWithCurrentMonth(t *testing.T) { }) require.NoError(t, err) now := time.Now().UTC() - _, _, _, err = clientcountutil.NewActivityLogData(client). + _, _, err = clientcountutil.NewActivityLogData(client). NewPreviousMonthData(3). // 3 months ago, 15 non-entity clients and 10 ACME clients NewClientsSeen(15, clientcountutil.WithClientType("non-entity-token")). @@ -116,7 +116,7 @@ func TestACMERegeneration_RegenerateMuchOlder(t *testing.T) { client := cluster.Cores[0].Client now := time.Now().UTC() - _, _, _, err := clientcountutil.NewActivityLogData(client). + _, _, err := clientcountutil.NewActivityLogData(client). NewPreviousMonthData(5). // 5 months ago, 15 non-entity clients and 10 ACME clients NewClientsSeen(15, clientcountutil.WithClientType("non-entity-token")). @@ -159,7 +159,7 @@ func TestACMERegeneration_RegeneratePreviousMonths(t *testing.T) { client := cluster.Cores[0].Client now := time.Now().UTC() - _, _, _, err := clientcountutil.NewActivityLogData(client). + _, _, err := clientcountutil.NewActivityLogData(client). NewPreviousMonthData(3). // 3 months ago, 15 non-entity clients and 10 ACME clients NewClientsSeen(15, clientcountutil.WithClientType("non-entity-token")). diff --git a/vault/external_tests/activity_testonly/activity_testonly_oss_test.go b/vault/external_tests/activity_testonly/activity_testonly_oss_test.go index 4b59142008b6..c5463bb801d2 100644 --- a/vault/external_tests/activity_testonly/activity_testonly_oss_test.go +++ b/vault/external_tests/activity_testonly/activity_testonly_oss_test.go @@ -29,7 +29,7 @@ func Test_ActivityLog_Disable(t *testing.T) { "enabled": "enable", }) require.NoError(t, err) - _, _, _, err = clientcountutil.NewActivityLogData(client). + _, _, err = clientcountutil.NewActivityLogData(client). NewPreviousMonthData(1). NewClientsSeen(5). NewCurrentMonthData(). diff --git a/vault/external_tests/activity_testonly/activity_testonly_test.go b/vault/external_tests/activity_testonly/activity_testonly_test.go index 3e3a1259b2e3..cd9dfb21574b 100644 --- a/vault/external_tests/activity_testonly/activity_testonly_test.go +++ b/vault/external_tests/activity_testonly/activity_testonly_test.go @@ -1,7 +1,7 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 -//go:build testonly +////go:build testonly package activity_testonly @@ -86,7 +86,7 @@ func Test_ActivityLog_LoseLeadership(t *testing.T) { }) require.NoError(t, err) - _, _, _, err = clientcountutil.NewActivityLogData(client). + _, _, err = clientcountutil.NewActivityLogData(client). NewCurrentMonthData(). NewClientsSeen(10). Write(context.Background(), generation.WriteOptions_WRITE_ENTITIES) @@ -121,7 +121,7 @@ func Test_ActivityLog_ClientsOverlapping(t *testing.T) { "enabled": "enable", }) require.NoError(t, err) - _, _, _, err = clientcountutil.NewActivityLogData(client). + _, _, err = clientcountutil.NewActivityLogData(client). NewPreviousMonthData(1). NewClientsSeen(7). NewCurrentMonthData(). @@ -169,7 +169,7 @@ func Test_ActivityLog_ClientsNewCurrentMonth(t *testing.T) { "enabled": "enable", }) require.NoError(t, err) - _, _, _, err = clientcountutil.NewActivityLogData(client). + _, _, err = clientcountutil.NewActivityLogData(client). NewPreviousMonthData(1). NewClientsSeen(5). NewCurrentMonthData(). @@ -203,7 +203,7 @@ func Test_ActivityLog_EmptyDataMonths(t *testing.T) { "enabled": "enable", }) require.NoError(t, err) - _, _, _, err = clientcountutil.NewActivityLogData(client). + _, _, err = clientcountutil.NewActivityLogData(client). NewCurrentMonthData(). NewClientsSeen(10). Write(context.Background(), generation.WriteOptions_WRITE_PRECOMPUTED_QUERIES, generation.WriteOptions_WRITE_ENTITIES) @@ -243,7 +243,7 @@ func Test_ActivityLog_FutureEndDate(t *testing.T) { "enabled": "enable", }) require.NoError(t, err) - _, _, _, err = clientcountutil.NewActivityLogData(client). + _, _, err = clientcountutil.NewActivityLogData(client). NewPreviousMonthData(1). NewClientsSeen(10). NewCurrentMonthData(). @@ -316,7 +316,7 @@ func Test_ActivityLog_ClientTypeResponse(t *testing.T) { _, err := client.Logical().Write("sys/internal/counters/config", map[string]interface{}{ "enabled": "enable", }) - _, _, _, err = clientcountutil.NewActivityLogData(client). + _, _, err = clientcountutil.NewActivityLogData(client). NewCurrentMonthData(). NewClientsSeen(10, clientcountutil.WithClientType(tc.clientType)). Write(context.Background(), generation.WriteOptions_WRITE_ENTITIES) @@ -369,7 +369,7 @@ func Test_ActivityLogCurrentMonth_Response(t *testing.T) { _, err := client.Logical().Write("sys/internal/counters/config", map[string]interface{}{ "enabled": "enable", }) - _, _, _, err = clientcountutil.NewActivityLogData(client). + _, _, err = clientcountutil.NewActivityLogData(client). NewCurrentMonthData(). NewClientsSeen(10, clientcountutil.WithClientType(tc.clientType)). Write(context.Background(), generation.WriteOptions_WRITE_ENTITIES) @@ -420,7 +420,7 @@ func Test_ActivityLog_Deduplication(t *testing.T) { _, err := client.Logical().Write("sys/internal/counters/config", map[string]interface{}{ "enabled": "enable", }) - _, _, _, err = clientcountutil.NewActivityLogData(client). + _, _, err = clientcountutil.NewActivityLogData(client). NewPreviousMonthData(3). NewClientsSeen(10, clientcountutil.WithClientType(tc.clientType)). NewPreviousMonthData(2). @@ -462,7 +462,7 @@ func Test_ActivityLog_MountDeduplication(t *testing.T) { require.NoError(t, err) now := time.Now().UTC() - _, localPaths, globalPaths, err := clientcountutil.NewActivityLogData(client). + localPaths, globalPaths, err := clientcountutil.NewActivityLogData(client). NewPreviousMonthData(1). NewClientSeen(clientcountutil.WithClientMount("sys")). NewClientSeen(clientcountutil.WithClientMount("secret")). @@ -673,7 +673,7 @@ func Test_ActivityLog_Export_Sudo(t *testing.T) { rootToken := client.Token() - _, _, _, err = clientcountutil.NewActivityLogData(client). + _, _, err = clientcountutil.NewActivityLogData(client). NewCurrentMonthData(). NewClientsSeen(10). Write(context.Background(), generation.WriteOptions_WRITE_ENTITIES) @@ -849,7 +849,7 @@ func TestHandleQuery_MultipleMounts(t *testing.T) { } // Write all the client count data - _, _, _, err = activityLogGenerator.Write(context.Background(), generation.WriteOptions_WRITE_PRECOMPUTED_QUERIES, generation.WriteOptions_WRITE_ENTITIES) + _, _, err = activityLogGenerator.Write(context.Background(), generation.WriteOptions_WRITE_PRECOMPUTED_QUERIES, generation.WriteOptions_WRITE_ENTITIES) require.NoError(t, err) endOfCurrentMonth := timeutil.EndOfMonth(time.Now().UTC()) diff --git a/vault/logical_system_activity_write_testonly.go b/vault/logical_system_activity_write_testonly.go index 3f6f4caa5663..51fe65e61e6d 100644 --- a/vault/logical_system_activity_write_testonly.go +++ b/vault/logical_system_activity_write_testonly.go @@ -85,14 +85,13 @@ func (b *SystemBackend) handleActivityWriteData(ctx context.Context, request *lo for _, opt := range input.Write { opts[opt] = struct{}{} } - paths, localPaths, globalPaths, err := generated.write(ctx, opts, b.Core.activityLog, now) + localPaths, globalPaths, err := generated.write(ctx, opts, b.Core.activityLog, now) if err != nil { b.logger.Debug("failed to write activity log data", "error", err.Error()) return logical.ErrorResponse("failed to write data"), err } return &logical.Response{ Data: map[string]interface{}{ - "paths": paths, "local_paths": localPaths, "global_paths": globalPaths, }, @@ -101,15 +100,10 @@ func (b *SystemBackend) handleActivityWriteData(ctx context.Context, request *lo // singleMonthActivityClients holds a single month's client IDs, in the order they were seen type singleMonthActivityClients struct { - // clients are indexed by ID - clients []*activity.EntityRecord // globalClients are indexed by ID globalClients []*activity.EntityRecord // localClients are indexed by ID localClients []*activity.EntityRecord - // predefinedSegments map from the segment number to the client's index in - // the clients slice - predefinedSegments map[int][]int // predefinedGlobalSegments map from the segment number to the client's index in // the clients slice predefinedGlobalSegments map[int][]int @@ -126,17 +120,13 @@ type multipleMonthsActivityClients struct { months []*singleMonthActivityClients } -func (s *singleMonthActivityClients) addEntityRecord(core *Core, record *activity.EntityRecord, segmentIndex *int) { - s.clients = append(s.clients, record) - local, _ := core.activityLog.isClientLocal(record) +func (s *singleMonthActivityClients) addEntityRecord(core *Core, record *activity.EntityRecord, segmentIndex *int, local bool) { if !local { s.globalClients = append(s.globalClients, record) } else { s.localClients = append(s.localClients, record) } if segmentIndex != nil { - index := len(s.clients) - 1 - s.predefinedSegments[*segmentIndex] = append(s.predefinedSegments[*segmentIndex], index) if !local { globalIndex := len(s.globalClients) - 1 s.predefinedGlobalSegments[*segmentIndex] = append(s.predefinedGlobalSegments[*segmentIndex], globalIndex) @@ -230,9 +220,15 @@ func (s *singleMonthActivityClients) addNewClients(c *generation.Client, mountAc if c.Count > 1 { count = int(c.Count) } - isNonEntity := c.ClientType != entityActivityType ts := timeutil.MonthsPreviousTo(int(monthsAgo), now) + // identify is client is local or global + isLocal, err := isClientLocal(core, c.ClientType, mountAccessor) + if err != nil { + return err + } + + isNonEntity := c.ClientType != entityActivityType for i := 0; i < count; i++ { record := &activity.EntityRecord{ ClientID: c.Id, @@ -250,7 +246,7 @@ func (s *singleMonthActivityClients) addNewClients(c *generation.Client, mountAc } } - s.addEntityRecord(core, record, segmentIndex) + s.addEntityRecord(core, record, segmentIndex, isLocal) } return nil } @@ -359,13 +355,25 @@ func (m *multipleMonthsActivityClients) addRepeatedClients(monthsAgo int32, c *g repeatedFromMonth = c.RepeatedFromMonth } repeatedFrom := m.months[repeatedFromMonth] + + // identify is client is local or global + isLocal, err := isClientLocal(core, c.ClientType, mountAccessor) + if err != nil { + return err + } + numClients := 1 if c.Count > 0 { numClients = int(c.Count) } - for _, client := range repeatedFrom.clients { + + repeatedClients := repeatedFrom.globalClients + if isLocal { + repeatedClients = repeatedFrom.localClients + } + for _, client := range repeatedClients { if c.ClientType == client.ClientType && mountAccessor == client.MountAccessor && c.Namespace == client.NamespaceID { - addingTo.addEntityRecord(core, client, segmentIndex) + addingTo.addEntityRecord(core, client, segmentIndex, isLocal) numClients-- if numClients == 0 { break @@ -378,6 +386,23 @@ func (m *multipleMonthsActivityClients) addRepeatedClients(monthsAgo int32, c *g return nil } +// isClientLocal checks whether the given client is on a local mount. +// In all other cases, we will assume it is a global client. +func isClientLocal(core *Core, clientType string, mountAccessor string) (bool, error) { + // Tokens are not replicated to performance secondary clusters + if clientType == nonEntityTokenActivityType { + return true, nil + } + mountEntry := core.router.MatchingMountByAccessor(mountAccessor) + // If the mount entry is nil, this means the mount has been deleted. We will assume it was replicated because we do not want to + // over count clients + if mountEntry != nil && mountEntry.Local { + return true, nil + } + + return false, nil +} + func (m *multipleMonthsActivityClients) addMissingCurrentMonth() { missing := m.months[0].generationParameters == nil && len(m.months) > 1 && @@ -395,8 +420,7 @@ func (m *multipleMonthsActivityClients) timestampForMonth(i int, now time.Time) return now } -func (m *multipleMonthsActivityClients) write(ctx context.Context, opts map[generation.WriteOptions]struct{}, activityLog *ActivityLog, now time.Time) ([]string, []string, []string, error) { - paths := []string{} +func (m *multipleMonthsActivityClients) write(ctx context.Context, opts map[generation.WriteOptions]struct{}, activityLog *ActivityLog, now time.Time) ([]string, []string, error) { globalPaths := []string{} localPaths := []string{} @@ -411,30 +435,10 @@ func (m *multipleMonthsActivityClients) write(ctx context.Context, opts map[gene continue } timestamp := m.timestampForMonth(i, now) - segments, err := month.populateSegments(month.predefinedSegments, month.clients) - if err != nil { - return nil, nil, nil, err - } - for segmentIndex, segment := range segments { - if segment == nil { - // skip the index - continue - } - entityPath, err := activityLog.saveSegmentEntitiesInternal(ctx, segmentInfo{ - startTimestamp: timestamp.Unix(), - currentClients: &activity.EntityActivityLog{Clients: segment}, - clientSequenceNumber: uint64(segmentIndex), - tokenCount: &activity.TokenCount{}, - }, true, "") - if err != nil { - return nil, nil, nil, err - } - paths = append(paths, entityPath) - } if len(month.globalClients) > 0 { globalSegments, err := month.populateSegments(month.predefinedGlobalSegments, month.globalClients) if err != nil { - return nil, nil, nil, err + return nil, nil, err } for segmentIndex, segment := range globalSegments { if segment == nil { @@ -448,7 +452,7 @@ func (m *multipleMonthsActivityClients) write(ctx context.Context, opts map[gene tokenCount: &activity.TokenCount{}, }, true, activityGlobalPathPrefix) if err != nil { - return nil, nil, nil, err + return nil, nil, err } globalPaths = append(globalPaths, entityPath) } @@ -456,7 +460,7 @@ func (m *multipleMonthsActivityClients) write(ctx context.Context, opts map[gene if len(month.localClients) > 0 { localSegments, err := month.populateSegments(month.predefinedLocalSegments, month.localClients) if err != nil { - return nil, nil, nil, err + return nil, nil, err } for segmentIndex, segment := range localSegments { if segment == nil { @@ -470,7 +474,7 @@ func (m *multipleMonthsActivityClients) write(ctx context.Context, opts map[gene tokenCount: &activity.TokenCount{}, }, true, activityLocalPathPrefix) if err != nil { - return nil, nil, nil, err + return nil, nil, err } localPaths = append(localPaths, entityPath) } @@ -495,16 +499,16 @@ func (m *multipleMonthsActivityClients) write(ctx context.Context, opts map[gene if writeIntentLog { err := activityLog.writeIntentLog(ctx, m.latestTimestamp(now, false).Unix(), m.latestTimestamp(now, true).UTC()) if err != nil { - return nil, nil, nil, err + return nil, nil, err } } wg := sync.WaitGroup{} err := activityLog.refreshFromStoredLog(ctx, &wg, now) if err != nil { - return nil, nil, nil, err + return nil, nil, err } wg.Wait() - return paths, localPaths, globalPaths, nil + return localPaths, globalPaths, nil } func (m *multipleMonthsActivityClients) latestTimestamp(now time.Time, includeCurrentMonth bool) time.Time { @@ -532,7 +536,6 @@ func newMultipleMonthsActivityClients(numberOfMonths int) *multipleMonthsActivit } for i := 0; i < numberOfMonths; i++ { m.months[i] = &singleMonthActivityClients{ - predefinedSegments: make(map[int][]int), predefinedGlobalSegments: make(map[int][]int), predefinedLocalSegments: make(map[int][]int), } @@ -583,12 +586,3 @@ func (p *sliceSegmentReader) ReadLocalEntity(ctx context.Context) (*activity.Ent func (p *sliceSegmentReader) ReadToken(ctx context.Context) (*activity.TokenCount, error) { return nil, io.EOF } - -func (p *sliceSegmentReader) ReadEntity(ctx context.Context) (*activity.EntityActivityLog, error) { - if p.i == len(p.records) { - return nil, io.EOF - } - record := p.records[p.i] - p.i++ - return &activity.EntityActivityLog{Clients: record}, nil -} diff --git a/vault/logical_system_activity_write_testonly_test.go b/vault/logical_system_activity_write_testonly_test.go index 4df992172d2b..420e2079d0ef 100644 --- a/vault/logical_system_activity_write_testonly_test.go +++ b/vault/logical_system_activity_write_testonly_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/hashicorp/vault/builtin/credential/userpass" "github.com/hashicorp/vault/helper/namespace" "github.com/hashicorp/vault/helper/timeutil" "github.com/hashicorp/vault/sdk/helper/clientcountutil/generation" @@ -26,11 +27,12 @@ import ( // correctly validated func TestSystemBackend_handleActivityWriteData(t *testing.T) { testCases := []struct { - name string - operation logical.Operation - input map[string]interface{} - wantError error - wantPaths int + name string + operation logical.Operation + input map[string]interface{} + hasLocalClients bool + wantError error + wantPaths int }{ { name: "read fails", @@ -84,6 +86,13 @@ func TestSystemBackend_handleActivityWriteData(t *testing.T) { input: map[string]interface{}{"input": `{"write":["WRITE_ENTITIES"],"data":[{"current_month":true,"num_segments":3,"all":{"clients":[{"count":5}]}}]}`}, wantPaths: 3, }, + { + name: "entities with multiple segments", + operation: logical.UpdateOperation, + input: map[string]interface{}{"input": `{"write":["WRITE_ENTITIES"],"data":[{"current_month":true,"num_segments":3,"all":{"clients":[{"count":5, "mount":"cubbyhole/"}]}}]}`}, + hasLocalClients: true, + wantPaths: 3, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -95,8 +104,16 @@ func TestSystemBackend_handleActivityWriteData(t *testing.T) { require.Equal(t, tc.wantError, err, resp.Error()) } else { require.NoError(t, err) - paths := resp.Data["paths"].([]string) - require.Len(t, paths, tc.wantPaths) + globalPaths := resp.Data["global_paths"].([]string) + localPaths := resp.Data["local_paths"].([]string) + if tc.hasLocalClients { + require.Len(t, globalPaths, 0) + require.Len(t, localPaths, tc.wantPaths) + } else { + require.Len(t, globalPaths, tc.wantPaths) + require.Len(t, localPaths, 0) + } + } }) } @@ -116,6 +133,7 @@ func Test_singleMonthActivityClients_addNewClients(t *testing.T) { wantNamespace string wantMount string wantID string + isLocal bool segmentIndex *int }{ { @@ -153,6 +171,13 @@ func Test_singleMonthActivityClients_addNewClients(t *testing.T) { ClientType: "non-entity", }, }, + { + name: "non entity token client", + clients: &generation.Client{ + ClientType: nonEntityTokenActivityType, + }, + isLocal: true, + }, { name: "acme client", clients: &generation.Client{ @@ -169,8 +194,8 @@ func Test_singleMonthActivityClients_addNewClients(t *testing.T) { t.Run(tt.name, func(t *testing.T) { core, _, _ := TestCoreUnsealed(t) m := &singleMonthActivityClients{ - predefinedSegments: make(map[int][]int), predefinedGlobalSegments: make(map[int][]int), + predefinedLocalSegments: make(map[int][]int), } err := m.addNewClients(tt.clients, tt.mount, tt.segmentIndex, 0, time.Now().UTC(), core) require.NoError(t, err) @@ -178,8 +203,16 @@ func Test_singleMonthActivityClients_addNewClients(t *testing.T) { if numNew == 0 { numNew = 1 } - require.Len(t, m.clients, int(numNew)) - for i, rec := range m.clients { + + var clients []*activity.EntityRecord + if tt.isLocal { + require.Len(t, m.localClients, int(numNew)) + clients = m.localClients + } else { + require.Len(t, m.globalClients, int(numNew)) + clients = m.globalClients + } + for i, rec := range clients { require.NotNil(t, rec) require.Equal(t, tt.wantNamespace, rec.NamespaceID) require.Equal(t, tt.wantMount, rec.MountAccessor) @@ -189,8 +222,11 @@ func Test_singleMonthActivityClients_addNewClients(t *testing.T) { } else { require.NotEqual(t, "", rec.ClientID) } - if tt.segmentIndex != nil { - require.Contains(t, m.predefinedSegments[*tt.segmentIndex], i) + if tt.segmentIndex != nil && tt.isLocal { + require.Contains(t, m.predefinedLocalSegments[*tt.segmentIndex], i) + } + if tt.segmentIndex != nil && !tt.isLocal { + require.Contains(t, m.predefinedGlobalSegments[*tt.segmentIndex], i) } } }) @@ -206,6 +242,7 @@ func Test_multipleMonthsActivityClients_processMonth(t *testing.T) { name string clients *generation.Data wantError bool + isLocal bool numMonths int }{ { @@ -218,6 +255,16 @@ func Test_multipleMonthsActivityClients_processMonth(t *testing.T) { }, numMonths: 1, }, + { + name: "specified namespace and local mount exist", + clients: &generation.Data{ + Clients: &generation.Data_All{All: &generation.Clients{Clients: []*generation.Client{{ + Mount: "cubbyhole/", + }}}}, + }, + numMonths: 1, + isLocal: true, + }, { name: "mount missing slash", clients: &generation.Data{ @@ -282,13 +329,24 @@ func Test_multipleMonthsActivityClients_processMonth(t *testing.T) { require.Error(t, err) } else { require.NoError(t, err) - require.Len(t, m.months[tt.clients.GetMonthsAgo()].clients, len(tt.clients.GetAll().Clients)) - for _, month := range m.months { - for _, c := range month.clients { - require.NotEmpty(t, c.NamespaceID) - require.NotEmpty(t, c.MountAccessor) + if tt.isLocal { + require.Len(t, m.months[tt.clients.GetMonthsAgo()].localClients, len(tt.clients.GetAll().Clients)) + for _, month := range m.months { + for _, c := range month.localClients { + require.NotEmpty(t, c.NamespaceID) + require.NotEmpty(t, c.MountAccessor) + } + } + } else { + require.Len(t, m.months[tt.clients.GetMonthsAgo()].globalClients, len(tt.clients.GetAll().Clients)) + for _, month := range m.months { + for _, c := range month.globalClients { + require.NotEmpty(t, c.NamespaceID) + require.NotEmpty(t, c.MountAccessor) + } } } + } }) } @@ -323,58 +381,95 @@ func Test_multipleMonthsActivityClients_processMonth_segmented(t *testing.T) { m := newMultipleMonthsActivityClients(1) core, _, _ := TestCoreUnsealed(t) require.NoError(t, m.processMonth(context.Background(), core, data, time.Now().UTC())) - require.Len(t, m.months[0].predefinedSegments, 3) - require.Len(t, m.months[0].clients, 3) + require.Len(t, m.months[0].predefinedGlobalSegments, 3) + require.Len(t, m.months[0].globalClients, 3) // segment indexes are correct - require.Contains(t, m.months[0].predefinedSegments, 0) - require.Contains(t, m.months[0].predefinedSegments, 1) - require.Contains(t, m.months[0].predefinedSegments, 7) + require.Contains(t, m.months[0].predefinedGlobalSegments, 0) + require.Contains(t, m.months[0].predefinedGlobalSegments, 1) + require.Contains(t, m.months[0].predefinedGlobalSegments, 7) // the data in each segment is correct - require.Contains(t, m.months[0].predefinedSegments[0], 0) - require.Contains(t, m.months[0].predefinedSegments[1], 1) - require.Contains(t, m.months[0].predefinedSegments[7], 2) + require.Contains(t, m.months[0].predefinedGlobalSegments[0], 0) + require.Contains(t, m.months[0].predefinedGlobalSegments[1], 1) + require.Contains(t, m.months[0].predefinedGlobalSegments[7], 2) } // Test_multipleMonthsActivityClients_addRepeatedClients adds repeated clients // from 1 month ago and 2 months ago, and verifies that the correct clients are // added based on namespace, mount, and non-entity attributes func Test_multipleMonthsActivityClients_addRepeatedClients(t *testing.T) { - core, _, _ := TestCoreUnsealed(t) + storage := &logical.InmemStorage{} + coreConfig := &CoreConfig{ + CredentialBackends: map[string]logical.Factory{ + "userpass": userpass.Factory, + }, + Physical: storage.Underlying(), + } + + cluster := NewTestCluster(t, coreConfig, nil) + core := cluster.Cores[0].Core now := time.Now().UTC() m := newMultipleMonthsActivityClients(3) defaultMount := "default" + // add global clients require.NoError(t, m.addClientToMonth(2, &generation.Client{Count: 2}, "identity", nil, now, core)) require.NoError(t, m.addClientToMonth(2, &generation.Client{Count: 2, Namespace: "other_ns"}, defaultMount, nil, now, core)) require.NoError(t, m.addClientToMonth(1, &generation.Client{Count: 2}, defaultMount, nil, now, core)) require.NoError(t, m.addClientToMonth(1, &generation.Client{Count: 2, ClientType: "non-entity"}, defaultMount, nil, now, core)) - month2Clients := m.months[2].clients - month1Clients := m.months[1].clients + // create a local mount + localMount := "localMountAccessor" + localMe := &MountEntry{ + Table: credentialTableType, + Path: "userpass-local/", + Type: "userpass", + Local: true, + Accessor: localMount, + } + err := core.enableCredential(namespace.RootContext(nil), localMe) + require.NoError(t, err) + + // add a local client + require.NoError(t, m.addClientToMonth(2, &generation.Client{Count: 2}, localMount, nil, now, core)) + require.NoError(t, m.addClientToMonth(1, &generation.Client{Count: 2}, localMount, nil, now, core)) + + month2GlobalClients := m.months[2].globalClients + month1GlobalClients := m.months[1].globalClients + + month2LocalClients := m.months[2].localClients + month1LocalClients := m.months[1].localClients thisMonth := m.months[0] // this will match the first client in month 1 require.NoError(t, m.addRepeatedClients(0, &generation.Client{Count: 1, Repeated: true}, defaultMount, nil, core)) - require.Contains(t, month1Clients, thisMonth.clients[0]) + require.Contains(t, month1GlobalClients, thisMonth.globalClients[0]) + + // this will match the first local client in month 1 + require.NoError(t, m.addRepeatedClients(0, &generation.Client{Count: 1, Repeated: true}, localMount, nil, core)) + require.Contains(t, month1LocalClients, thisMonth.localClients[0]) // this will match the 3rd client in month 1 require.NoError(t, m.addRepeatedClients(0, &generation.Client{Count: 1, Repeated: true, ClientType: "non-entity"}, defaultMount, nil, core)) - require.Equal(t, month1Clients[2], thisMonth.clients[1]) + require.Equal(t, month1GlobalClients[2], thisMonth.globalClients[1]) // this will match the first two clients in month 1 require.NoError(t, m.addRepeatedClients(0, &generation.Client{Count: 2, Repeated: true}, defaultMount, nil, core)) - require.Equal(t, month1Clients[0:2], thisMonth.clients[2:4]) + require.Equal(t, month1GlobalClients[0:2], thisMonth.globalClients[2:4]) // this will match the first client in month 2 require.NoError(t, m.addRepeatedClients(0, &generation.Client{Count: 1, RepeatedFromMonth: 2}, "identity", nil, core)) - require.Equal(t, month2Clients[0], thisMonth.clients[4]) + require.Equal(t, month2GlobalClients[0], thisMonth.globalClients[4]) + + // this will match the first local client in month 2 + require.NoError(t, m.addRepeatedClients(0, &generation.Client{Count: 1, RepeatedFromMonth: 2}, localMount, nil, core)) + require.Equal(t, month2LocalClients[0], thisMonth.localClients[1]) // this will match the 3rd client in month 2 require.NoError(t, m.addRepeatedClients(0, &generation.Client{Count: 1, RepeatedFromMonth: 2, Namespace: "other_ns"}, defaultMount, nil, core)) - require.Equal(t, month2Clients[2], thisMonth.clients[5]) + require.Equal(t, month2GlobalClients[2], thisMonth.globalClients[5]) require.Error(t, m.addRepeatedClients(0, &generation.Client{Count: 1, RepeatedFromMonth: 2, Namespace: "other_ns"}, "other_mount", nil, core)) } @@ -458,8 +553,8 @@ func Test_singleMonthActivityClients_populateSegments(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - s := singleMonthActivityClients{predefinedSegments: tc.segments, clients: clients, generationParameters: &generation.Data{EmptySegmentIndexes: tc.emptyIndexes, SkipSegmentIndexes: tc.skipIndexes, NumSegments: int32(tc.numSegments)}} - gotSegments, err := s.populateSegments(s.predefinedSegments, s.clients) + s := singleMonthActivityClients{predefinedGlobalSegments: tc.segments, globalClients: clients, generationParameters: &generation.Data{EmptySegmentIndexes: tc.emptyIndexes, SkipSegmentIndexes: tc.skipIndexes, NumSegments: int32(tc.numSegments)}} + gotSegments, err := s.populateSegments(s.predefinedGlobalSegments, s.globalClients) require.NoError(t, err) require.Equal(t, tc.wantSegments, gotSegments) }) @@ -529,7 +624,7 @@ func Test_handleActivityWriteData(t *testing.T) { req.Data = map[string]interface{}{"input": string(marshaled)} resp, err := core.systemBackend.HandleRequest(namespace.RootContext(nil), req) require.NoError(t, err) - paths := resp.Data["paths"].([]string) + paths := resp.Data["global_paths"].([]string) require.Len(t, paths, 9) times, err := core.activityLog.availableLogs(context.Background(), time.Now()) From 29d14c48f219a0350868ecfbc055f35d5ee1a77f Mon Sep 17 00:00:00 2001 From: Sarah Chavis <62406755+schavis@users.noreply.github.com> Date: Mon, 2 Dec 2024 15:27:32 -0800 Subject: [PATCH 09/45] [DOCS] Clarify maintenance table (#29069) The "Feature updates and improvements" line is misleading since we do not backport feature updates to N-1 or N-2. Since the answer is "no" for both columns, it creates less confusion to just delete it. --- website/content/docs/enterprise/lts.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/website/content/docs/enterprise/lts.mdx b/website/content/docs/enterprise/lts.mdx index 1940abb883df..0393fc19f82f 100644 --- a/website/content/docs/enterprise/lts.mdx +++ b/website/content/docs/enterprise/lts.mdx @@ -117,7 +117,6 @@ with patches for bugs that may cause outages and critical vulnerabilities and ex Maintenance updates | Standard maintenance | Extended maintenance --------------------------------- | -------------------- | -------------------- Performance improvements | YES | NO -Feature updates and improvements | YES | NO Bug fixes | YES | OUTAGE-RISK ONLY Security patches | YES | HIGH-RISK ONLY CVE patches | YES | YES From 5ed2f81102ce3a2516774ba5dd821e59efaa8ac5 Mon Sep 17 00:00:00 2001 From: miagilepner Date: Tue, 3 Dec 2024 13:27:29 +0100 Subject: [PATCH 10/45] VAULT-32568: Shutdown node when it's not in the raft config (#29052) * add implementation and tests * add eventually condition for test flake --- physical/raft/raft.go | 31 +++++++++++++++++++++++--- physical/raft/raft_test.go | 6 +++++ vault/core.go | 6 +++++ vault/external_tests/raft/raft_test.go | 26 ++++++++++++++++++++- vault/init.go | 2 +- vault/raft.go | 5 ++++- vault/request_forwarding.go | 2 +- vault/testing.go | 1 + 8 files changed, 72 insertions(+), 7 deletions(-) diff --git a/physical/raft/raft.go b/physical/raft/raft.go index de828377c002..043482bf0a6e 100644 --- a/physical/raft/raft.go +++ b/physical/raft/raft.go @@ -256,7 +256,8 @@ type RaftBackend struct { // limits. specialPathLimits map[string]uint64 - removed atomic.Bool + removed atomic.Bool + removedCallback func() } func (b *RaftBackend) IsNodeRemoved(ctx context.Context, nodeID string) (bool, error) { @@ -1030,6 +1031,12 @@ func (b *RaftBackend) SetRestoreCallback(restoreCb restoreCallback) { b.fsm.l.Unlock() } +func (b *RaftBackend) SetRemovedCallback(cb func()) { + b.l.Lock() + defer b.l.Unlock() + b.removedCallback = cb +} + func (b *RaftBackend) applyConfigSettings(config *raft.Config) error { config.Logger = b.logger multiplierRaw, ok := b.conf["performance_multiplier"] @@ -1107,9 +1114,12 @@ type SetupOpts struct { // We pass it in though because it can be overridden in tests or via ENV in // core. EffectiveSDKVersion string + + // RemovedCallback is the function to call when the node has been removed + RemovedCallback func() } -func (b *RaftBackend) StartRecoveryCluster(ctx context.Context, peer Peer) error { +func (b *RaftBackend) StartRecoveryCluster(ctx context.Context, peer Peer, removedCallback func()) error { recoveryModeConfig := &raft.Configuration{ Servers: []raft.Server{ { @@ -1122,6 +1132,7 @@ func (b *RaftBackend) StartRecoveryCluster(ctx context.Context, peer Peer) error return b.SetupCluster(context.Background(), SetupOpts{ StartAsLeader: true, RecoveryModeConfig: recoveryModeConfig, + RemovedCallback: removedCallback, }) } @@ -1391,6 +1402,9 @@ func (b *RaftBackend) SetupCluster(ctx context.Context, opts SetupOpts) error { } } + if opts.RemovedCallback != nil { + b.removedCallback = opts.RemovedCallback + } b.StartRemovedChecker(ctx) b.logger.Trace("finished setting up raft cluster") @@ -1430,6 +1444,7 @@ func (b *RaftBackend) StartRemovedChecker(ctx context.Context) { go func() { ticker := time.NewTicker(time.Second) defer ticker.Stop() + hasBeenPresent := false logger := b.logger.Named("removed.checker") for { @@ -1440,11 +1455,21 @@ func (b *RaftBackend) StartRemovedChecker(ctx context.Context) { logger.Error("failed to check if node is removed", "node ID", b.localID, "error", err) continue } - if removed { + if !removed { + hasBeenPresent = true + } + // the node must have been previously present in the config, + // only then should we consider it removed and shutdown + if removed && hasBeenPresent { err := b.RemoveSelf() if err != nil { logger.Error("failed to remove self", "node ID", b.localID, "error", err) } + b.l.RLock() + if b.removedCallback != nil { + b.removedCallback() + } + b.l.RUnlock() return } case <-ctx.Done(): diff --git a/physical/raft/raft_test.go b/physical/raft/raft_test.go index 70b14c1b7b75..b4e0f11609f9 100644 --- a/physical/raft/raft_test.go +++ b/physical/raft/raft_test.go @@ -15,6 +15,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "testing" "time" @@ -785,10 +786,15 @@ func TestRaft_Removed(t *testing.T) { require.False(t, raft2.IsRemoved()) require.False(t, raft3.IsRemoved()) + callbackCalled := atomic.Bool{} + raft3.SetRemovedCallback(func() { + callbackCalled.Store(true) + }) err := raft1.RemovePeer(context.Background(), raft3.NodeID()) require.NoError(t, err) require.Eventually(t, raft3.IsRemoved, 15*time.Second, 500*time.Millisecond) + require.True(t, callbackCalled.Load()) require.False(t, raft1.IsRemoved()) require.False(t, raft2.IsRemoved()) }) diff --git a/vault/core.go b/vault/core.go index 706059667e85..6c9c90087ee8 100644 --- a/vault/core.go +++ b/vault/core.go @@ -4626,3 +4626,9 @@ func (c *Core) IsRemovedFromCluster() (removed, ok bool) { return removableNodeHA.IsRemoved(), true } + +func (c *Core) shutdownRemovedNode() { + go func() { + c.ShutdownCoreError(errors.New("node has been removed from cluster")) + }() +} diff --git a/vault/external_tests/raft/raft_test.go b/vault/external_tests/raft/raft_test.go index 33dc48d67c43..8c3b36ae6e99 100644 --- a/vault/external_tests/raft/raft_test.go +++ b/vault/external_tests/raft/raft_test.go @@ -1388,5 +1388,29 @@ func TestRaftCluster_Removed(t *testing.T) { "test": "other_data", }) require.Error(t, err) - require.True(t, follower.Sealed()) + require.Eventually(t, follower.Sealed, 3*time.Second, 250*time.Millisecond) +} + +// TestRaftCluster_Removed_RaftConfig creates a 3 node raft cluster with an extremely long +// heartbeat interval, and then removes one of the nodes. The test verifies that +// removed node discovers that it has been removed (via not being present in the +// raft config) and seals. +func TestRaftCluster_Removed_RaftConfig(t *testing.T) { + t.Parallel() + conf, opts := raftClusterBuilder(t, nil) + conf.ClusterHeartbeatInterval = 5 * time.Minute + cluster := vault.NewTestCluster(t, conf, &opts) + vault.TestWaitActive(t, cluster.Cores[0].Core) + + follower := cluster.Cores[2] + followerClient := follower.Client + _, err := followerClient.Logical().Write("secret/foo", map[string]interface{}{ + "test": "data", + }) + require.NoError(t, err) + + _, err = cluster.Cores[0].Client.Logical().Write("/sys/storage/raft/remove-peer", map[string]interface{}{ + "server_id": follower.NodeID, + }) + require.Eventually(t, follower.Sealed, 10*time.Second, 500*time.Millisecond) } diff --git a/vault/init.go b/vault/init.go index efec8677fe2e..a3564713a7b2 100644 --- a/vault/init.go +++ b/vault/init.go @@ -62,7 +62,7 @@ func (c *Core) InitializeRecovery(ctx context.Context) error { return raftStorage.StartRecoveryCluster(context.Background(), raft.Peer{ ID: raftStorage.NodeID(), Address: parsedClusterAddr.Host, - }) + }, c.shutdownRemovedNode) }) return nil diff --git a/vault/raft.go b/vault/raft.go index 416334868f9b..cfffcf10196a 100644 --- a/vault/raft.go +++ b/vault/raft.go @@ -166,6 +166,7 @@ func (c *Core) startRaftBackend(ctx context.Context) (retErr error) { ClusterListener: c.getClusterListener(), StartAsLeader: creating, EffectiveSDKVersion: c.effectiveSDKVersion, + RemovedCallback: c.shutdownRemovedNode, }); err != nil { return err } @@ -1417,6 +1418,7 @@ func (c *Core) joinRaftSendAnswer(ctx context.Context, sealAccess seal.Access, r opts := raft.SetupOpts{ TLSKeyring: answerResp.Data.TLSKeyring, ClusterListener: c.getClusterListener(), + RemovedCallback: c.shutdownRemovedNode, } err = raftBackend.SetupCluster(ctx, opts) if err != nil { @@ -1472,7 +1474,8 @@ func (c *Core) RaftBootstrap(ctx context.Context, onInit bool) error { } raftOpts := raft.SetupOpts{ - StartAsLeader: true, + StartAsLeader: true, + RemovedCallback: c.shutdownRemovedNode, } if !onInit { diff --git a/vault/request_forwarding.go b/vault/request_forwarding.go index 51dfa3a9093f..a0857c061376 100644 --- a/vault/request_forwarding.go +++ b/vault/request_forwarding.go @@ -105,7 +105,7 @@ func haMembershipClientCheck(err error, c *Core, haBackend physical.RemovableNod if removeErr != nil { c.logger.Debug("failed to remove self", "error", removeErr) } - go c.ShutdownCoreError(errors.New("node removed from HA configuration")) + c.shutdownRemovedNode() } func haMembershipUnaryClientInterceptor(c *Core, haBackend physical.RemovableNodeHABackend) grpc.UnaryClientInterceptor { diff --git a/vault/testing.go b/vault/testing.go index e6f1f26fcf31..41bf429acd4c 100644 --- a/vault/testing.go +++ b/vault/testing.go @@ -1451,6 +1451,7 @@ func NewTestCluster(t testing.TB, base *CoreConfig, opts *TestClusterOptions) *T } if base != nil { + coreConfig.ClusterHeartbeatInterval = base.ClusterHeartbeatInterval coreConfig.DetectDeadlocks = TestDeadlockDetection coreConfig.RawConfig = base.RawConfig coreConfig.DisableCache = base.DisableCache From 93f5777f6f013636b27c87566ed76e7af6b2dbe9 Mon Sep 17 00:00:00 2001 From: vinay-gopalan <86625824+vinay-gopalan@users.noreply.github.com> Date: Tue, 3 Dec 2024 11:29:13 -0800 Subject: [PATCH 11/45] Update DB Static role rotation logic to generate new password if retried password fails (#28989) --- builtin/logical/database/path_roles_test.go | 70 +++++++++++++++++++++ builtin/logical/database/rotation.go | 12 ++++ changelog/28989.txt | 3 + 3 files changed, 85 insertions(+) create mode 100644 changelog/28989.txt diff --git a/builtin/logical/database/path_roles_test.go b/builtin/logical/database/path_roles_test.go index 41a2e99758aa..0ad01efe02bc 100644 --- a/builtin/logical/database/path_roles_test.go +++ b/builtin/logical/database/path_roles_test.go @@ -1087,6 +1087,76 @@ func TestBackend_StaticRole_Role_name_check(t *testing.T) { } } +// TestStaticRole_NewCredentialGeneration verifies that new +// credentials are generated if a retried credential continues +// to fail +func TestStaticRole_NewCredentialGeneration(t *testing.T) { + ctx := context.Background() + b, storage, mockDB := getBackend(t) + defer b.Cleanup(ctx) + configureDBMount(t, storage) + + roleName := "hashicorp" + createRole(t, b, storage, mockDB, "hashicorp") + + t.Run("rotation failures should generate new password on retry", func(t *testing.T) { + // Fail to rotate the role + generateWALFromFailedRotation(t, b, storage, mockDB, roleName) + + // Get WAL + walIDs := requireWALs(t, storage, 1) + wal, err := b.findStaticWAL(ctx, storage, walIDs[0]) + if err != nil || wal == nil { + t.Fatal(err) + } + + // Store password + initialPassword := wal.NewPassword + + // Rotate role manually and fail again #1 with same password + generateWALFromFailedRotation(t, b, storage, mockDB, roleName) + + // Ensure WAL is deleted since retrying password failed + requireWALs(t, storage, 0) + + // Successfully rotate the role + mockDB.On("UpdateUser", mock.Anything, mock.Anything). + Return(v5.UpdateUserResponse{}, nil). + Once() + _, err = b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.UpdateOperation, + Path: "rotate-role/" + roleName, + Storage: storage, + }) + if err != nil { + t.Fatal(err) + } + + // Ensure WAL is flushed since request was successful + requireWALs(t, storage, 0) + + // Read the credential + data := map[string]interface{}{} + req := &logical.Request{ + Operation: logical.ReadOperation, + Path: "static-creds/" + roleName, + Storage: storage, + Data: data, + } + + resp, err := b.HandleRequest(namespace.RootContext(nil), req) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + // Confirm successful rotation used new credential + // Assert previous failing credential is not being used + if resp.Data["password"] == initialPassword { + t.Fatalf("expected password to be different after second retry") + } + }) +} + func TestWALsStillTrackedAfterUpdate(t *testing.T) { ctx := context.Background() b, storage, mockDB := getBackend(t) diff --git a/builtin/logical/database/rotation.go b/builtin/logical/database/rotation.go index d4d41cf570b5..3d460915a6c0 100644 --- a/builtin/logical/database/rotation.go +++ b/builtin/logical/database/rotation.go @@ -421,6 +421,7 @@ func (b *databaseBackend) setStaticAccount(ctx context.Context, s logical.Storag // Use credential from input if available. This happens if we're restoring from // a WAL item or processing the rotation queue with an item that has a WAL // associated with it + var usedCredentialFromPreviousRotation bool if output.WALID != "" { wal, err := b.findStaticWAL(ctx, s, output.WALID) if err != nil { @@ -448,6 +449,7 @@ func (b *databaseBackend) setStaticAccount(ctx context.Context, s logical.Storag Statements: statements, } input.Role.StaticAccount.Password = wal.NewPassword + usedCredentialFromPreviousRotation = true case wal.CredentialType == v5.CredentialTypeRSAPrivateKey: // Roll forward by using the credential in the existing WAL entry updateReq.CredentialType = v5.CredentialTypeRSAPrivateKey @@ -456,6 +458,7 @@ func (b *databaseBackend) setStaticAccount(ctx context.Context, s logical.Storag Statements: statements, } input.Role.StaticAccount.PrivateKey = wal.NewPrivateKey + usedCredentialFromPreviousRotation = true } } @@ -530,6 +533,15 @@ func (b *databaseBackend) setStaticAccount(ctx context.Context, s logical.Storag _, err = dbi.database.UpdateUser(ctx, updateReq, false) if err != nil { b.CloseIfShutdown(dbi, err) + if usedCredentialFromPreviousRotation { + b.Logger().Debug("credential stored in WAL failed, deleting WAL", "role", input.RoleName, "WAL ID", output.WALID) + if err := framework.DeleteWAL(ctx, s, output.WALID); err != nil { + b.Logger().Warn("failed to delete WAL", "error", err, "WAL ID", output.WALID) + } + + // Generate a new WAL entry and credential for next attempt + output.WALID = "" + } return output, fmt.Errorf("error setting credentials: %w", err) } modified = true diff --git a/changelog/28989.txt b/changelog/28989.txt new file mode 100644 index 000000000000..2e5068baeaa8 --- /dev/null +++ b/changelog/28989.txt @@ -0,0 +1,3 @@ +```release-note:bug +secret/db: Update static role rotation to generate a new password after 2 failed attempts. +``` \ No newline at end of file From 9ba62bec6f1005782c508a6dc45e6cc79c06beb6 Mon Sep 17 00:00:00 2001 From: divyaac Date: Tue, 3 Dec 2024 12:37:58 -0800 Subject: [PATCH 12/45] Migrate Clients From Old Storage Paths to New Paths During Upgrade #7032 (#29076) * Apply OSS Patch * Fix stub issues --- vault/activity_log.go | 574 +++++++++++++++++++------ vault/activity_log_stubs_oss.go | 13 +- vault/activity_log_test.go | 277 ++++++++++++ vault/activity_log_util_common.go | 96 +++++ vault/activity_log_util_common_test.go | 56 +++ 5 files changed, 895 insertions(+), 121 deletions(-) diff --git a/vault/activity_log.go b/vault/activity_log.go index 757165f3e1f1..1a9b23f4038d 100644 --- a/vault/activity_log.go +++ b/vault/activity_log.go @@ -46,7 +46,9 @@ const ( activityGlobalPathPrefix = "global/" activityLocalPathPrefix = "local/" - activityACMERegenerationKey = "acme-regeneration" + activityACMERegenerationKey = "acme-regeneration" + activityDeduplicationUpgradeKey = "deduplication-upgrade" + // sketch for each month that stores hash of client ids distinctClientsBasePath = "log/distinctclients/" @@ -114,6 +116,8 @@ const ( // CSV encoder. Indexes will be generated to ensure that values are slotted into the // correct column. This initial value is used prior to finalizing the CSV header. exportCSVFlatteningInitIndex = -1 + + DeduplicatedClientMinimumVersion = "1.19.0" ) var ( @@ -196,6 +200,11 @@ type ActivityLog struct { // Channel to stop background processing doneCh chan struct{} + // Channel to signal global clients have received by the primary from the secondary, during upgrade to 1.19 + dedupUpgradeGlobalClientsReceivedCh chan struct{} + // track whether the current cluster is in the middle of an upgrade to 1.19 + dedupClientsUpgradeComplete *atomic.Bool + // track metadata and contents of the most recent log segment currentSegment segmentInfo @@ -224,8 +233,18 @@ type ActivityLog struct { // channel closed when deletion at startup is done // (for unit test robustness) - retentionDone chan struct{} + retentionDone chan struct{} + // This channel is relevant for upgrades to 1.17. It indicates whether precomputed queries have been + // generated for ACME clients. computationWorkerDone chan struct{} + // This channel is relevant for upgrades to 1.19+ (version with deduplication of clients) + // This indicates that paths that were used before 1.19 to store clients have been cleaned + oldStoragePathsCleaned chan struct{} + + // channel to indicate that a global clients have been + // sent to the primary from a secondary + globalClientsSent chan struct{} + clientsReceivedForMigration map[int64][]*activity.LogFragment // for testing: is config currently being invalidated. protected by l configInvalidationInProgress bool @@ -350,19 +369,21 @@ func NewActivityLog(core *Core, logger log.Logger, view *BarrierView, metrics me clock = timeutil.DefaultClock{} } a := &ActivityLog{ - core: core, - configOverrides: &core.activityLogConfig, - logger: logger, - view: view, - metrics: metrics, - nodeID: hostname, - newFragmentCh: make(chan struct{}, 1), - sendCh: make(chan struct{}, 1), // buffered so it can be triggered by fragment size - doneCh: make(chan struct{}, 1), - partialMonthLocalClientTracker: make(map[string]*activity.EntityRecord), - newGlobalClientFragmentCh: make(chan struct{}, 1), - globalPartialMonthClientTracker: make(map[string]*activity.EntityRecord), - clock: clock, + core: core, + configOverrides: &core.activityLogConfig, + logger: logger, + view: view, + metrics: metrics, + nodeID: hostname, + newFragmentCh: make(chan struct{}, 1), + sendCh: make(chan struct{}, 1), // buffered so it can be triggered by fragment size + doneCh: make(chan struct{}, 1), + partialMonthLocalClientTracker: make(map[string]*activity.EntityRecord), + newGlobalClientFragmentCh: make(chan struct{}, 1), + dedupUpgradeGlobalClientsReceivedCh: make(chan struct{}, 1), + clientsReceivedForMigration: make(map[int64][]*activity.LogFragment), + globalPartialMonthClientTracker: make(map[string]*activity.EntityRecord), + clock: clock, currentSegment: segmentInfo{ startTimestamp: 0, currentClients: &activity.EntityActivityLog{ @@ -407,6 +428,7 @@ func NewActivityLog(core *Core, logger log.Logger, view *BarrierView, metrics me secondaryGlobalClientFragments: make([]*activity.LogFragment, 0), inprocessExport: atomic.NewBool(false), precomputedQueryWritten: make(chan struct{}), + dedupClientsUpgradeComplete: atomic.NewBool(false), } config, err := a.loadConfigOrDefault(core.activeContext) @@ -459,6 +481,8 @@ func (a *ActivityLog) saveCurrentSegmentToStorageLocked(ctx context.Context, for a.currentGlobalFragment = nil a.globalFragmentLock.Unlock() + globalFragments := append(append(secondaryGlobalClients, globalClients), standbyGlobalClients...) + if !a.core.IsPerfSecondary() { if a.currentGlobalFragment != nil { a.metrics.IncrCounterWithLabels([]string{"core", "activity", "global_fragment_size"}, @@ -467,19 +491,24 @@ func (a *ActivityLog) saveCurrentSegmentToStorageLocked(ctx context.Context, for {"type", "client"}, }) } - var globalReceivedFragmentTotal int - for _, globalReceivedFragment := range secondaryGlobalClients { - globalReceivedFragmentTotal += len(globalReceivedFragment.Clients) - } - for _, globalReceivedFragment := range standbyGlobalClients { - globalReceivedFragmentTotal += len(globalReceivedFragment.Clients) - } a.metrics.IncrCounterWithLabels([]string{"core", "activity", "global_received_fragment_size"}, - float32(globalReceivedFragmentTotal), + float32(len(globalFragments)), []metricsutil.Label{ {"type", "client"}, }) + // Since we are the primary, store global clients + // Create fragments from global clients and store the segment + if ret := a.createCurrentSegmentFromFragments(ctx, globalFragments, &a.currentGlobalSegment, force, activityGlobalPathPrefix); ret != nil { + return ret + } + + } else if !a.dedupClientsUpgradeComplete.Load() { + // We are the secondary, and an upgrade is in progress. In this case we will temporarily store the data at this old path + // This data will be garbage collected after the upgrade has completed + if ret := a.createCurrentSegmentFromFragments(ctx, globalFragments, &a.currentSegment, force, ""); ret != nil { + return ret + } } // If segment start time is zero, do not update or write @@ -489,15 +518,6 @@ func (a *ActivityLog) saveCurrentSegmentToStorageLocked(ctx context.Context, for return nil } - // If we are the primary, store global clients - // Create fragments from global clients and store the segment - if !a.core.IsPerfSecondary() { - globalFragments := append(append(secondaryGlobalClients, globalClients), standbyGlobalClients...) - if ret := a.createCurrentSegmentFromFragments(ctx, globalFragments, &a.currentGlobalSegment, force, activityGlobalPathPrefix); ret != nil { - return ret - } - } - // Swap out the pending local fragments a.localFragmentLock.Lock() localFragment := a.localFragment @@ -615,6 +635,74 @@ func (a *ActivityLog) createCurrentSegmentFromFragments(ctx context.Context, fra return nil } +func (a *ActivityLog) savePreviousTokenSegments(ctx context.Context, startTime int64, pathPrefix string, fragments []*activity.LogFragment) error { + tokenByNamespace := make(map[string]uint64) + for _, fragment := range fragments { + // As of 1.9, a fragment should no longer have any NonEntityTokens. However + // in order to not lose any information about the current segment during the + // month when the client upgrades to 1.9, we must retain this functionality. + for ns, val := range fragment.NonEntityTokens { + // We track these pre-1.9 values in the old location, which is + // a.currentSegment.tokenCount, as opposed to the counter that stores tokens + // without entities that have client IDs, namely + // a.partialMonthClientTracker.nonEntityCountByNamespaceID. This preserves backward + // compatibility for the precomputedQueryWorkers and the segment storing + // logic. + tokenByNamespace[ns] += val + } + } + segmentToStore := segmentInfo{ + startTimestamp: startTime, + clientSequenceNumber: 0, + currentClients: &activity.EntityActivityLog{ + Clients: make([]*activity.EntityRecord, 0), + }, + tokenCount: &activity.TokenCount{CountByNamespaceID: tokenByNamespace}, + } + + if _, err := a.saveSegmentEntitiesInternal(ctx, segmentToStore, false, pathPrefix); err != nil { + return err + } + return nil +} + +func (a *ActivityLog) savePreviousEntitySegments(ctx context.Context, startTime int64, pathPrefix string, allFragments []*activity.LogFragment) error { + deduplicatedClients := make(map[string]*activity.EntityRecord) + for _, f := range allFragments { + for _, entity := range f.GetClients() { + deduplicatedClients[entity.ClientID] = entity + } + } + + segmentToStore := segmentInfo{ + startTimestamp: startTime, + clientSequenceNumber: 0, + currentClients: &activity.EntityActivityLog{ + Clients: make([]*activity.EntityRecord, 0), + }, + } + incrementSegmentNum := func() { + segmentToStore.clientSequenceNumber = segmentToStore.clientSequenceNumber + 1 + segmentToStore.currentClients.Clients = make([]*activity.EntityRecord, 0) + } + numAddedClients := 0 + for _, entity := range deduplicatedClients { + segmentToStore.currentClients.Clients = append(segmentToStore.currentClients.Clients, entity) + numAddedClients++ + if numAddedClients%ActivitySegmentClientCapacity == 0 { + if _, err := a.saveSegmentEntitiesInternal(ctx, segmentToStore, false, pathPrefix); err != nil { + return err + } + incrementSegmentNum() + } + } + // Store any remaining clients if they exist + if _, err := a.saveSegmentEntitiesInternal(ctx, segmentToStore, false, pathPrefix); err != nil { + return err + } + return nil +} + // :force: forces a save of tokens/entities even if the in-memory log is empty func (a *ActivityLog) saveCurrentSegmentInternal(ctx context.Context, force bool, currentSegment segmentInfo, storagePathPrefix string) error { _, err := a.saveSegmentEntitiesInternal(ctx, currentSegment, force, storagePathPrefix) @@ -711,28 +799,30 @@ func parseSegmentNumberFromPath(path string) (int, bool) { // availableLogs returns the start_time(s) (in UTC) associated with months for which logs exist, // sorted last to first func (a *ActivityLog) availableLogs(ctx context.Context, upTo time.Time) ([]time.Time, error) { - paths := make([]string, 0) - for _, basePath := range []string{activityLocalPathPrefix + activityEntityBasePath, activityGlobalPathPrefix + activityEntityBasePath, activityTokenLocalBasePath} { - p, err := a.view.List(ctx, basePath) - if err != nil { - return nil, err - } + pathSet := make(map[time.Time]struct{}) + out := make([]time.Time, 0) + availableTimes := make([]time.Time, 0) - paths = append(paths, p...) + times, err := a.availableTimesAtPath(ctx, upTo, activityTokenLocalBasePath) + if err != nil { + return nil, err } + availableTimes = append(availableTimes, times...) - pathSet := make(map[time.Time]struct{}) - out := make([]time.Time, 0) - for _, path := range paths { - // generate a set of unique start times - segmentTime, err := timeutil.ParseTimeFromPath(path) - if err != nil { - return nil, err - } - if segmentTime.After(upTo) { - continue - } + times, err = a.availableTimesAtPath(ctx, upTo, activityGlobalPathPrefix+activityEntityBasePath) + if err != nil { + return nil, err + } + availableTimes = append(availableTimes, times...) + + times, err = a.availableTimesAtPath(ctx, upTo, activityLocalPathPrefix+activityEntityBasePath) + if err != nil { + return nil, err + } + availableTimes = append(availableTimes, times...) + // Remove duplicate start times + for _, segmentTime := range availableTimes { if _, present := pathSet[segmentTime]; !present { pathSet[segmentTime] = struct{}{} out = append(out, segmentTime) @@ -749,6 +839,27 @@ func (a *ActivityLog) availableLogs(ctx context.Context, upTo time.Time) ([]time return out, nil } +// availableTimesAtPath returns a sorted list of all available times at the pathPrefix up until the provided time. +func (a *ActivityLog) availableTimesAtPath(ctx context.Context, onlyIncludeTimesUpTo time.Time, path string) ([]time.Time, error) { + paths, err := a.view.List(ctx, path) + if err != nil { + return nil, err + } + out := make([]time.Time, 0) + for _, path := range paths { + // generate a set of unique start times + segmentTime, err := timeutil.ParseTimeFromPath(path) + if err != nil { + return nil, err + } + if segmentTime.After(onlyIncludeTimesUpTo) { + continue + } + out = append(out, segmentTime) + } + return out, nil +} + // getMostRecentActivityLogSegment gets the times (in UTC) associated with the most recent // contiguous set of activity logs, sorted in decreasing order (latest to earliest) func (a *ActivityLog) getMostRecentActivityLogSegment(ctx context.Context, now time.Time) ([]time.Time, error) { @@ -877,54 +988,42 @@ func (a *ActivityLog) loadPriorEntitySegment(ctx context.Context, startTime time // load all the active global clients if !isLocal { globalPath := activityGlobalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(sequenceNum, 10) - data, err := a.view.Get(ctx, globalPath) - if err != nil { + out, err := a.readEntitySegmentAtPath(ctx, globalPath) + if err != nil && !errors.Is(err, ErrEmptyResponse) { return err } - if data == nil { - return nil + if out != nil { + a.globalFragmentLock.Lock() + // Handle the (unlikely) case where the end of the month has been reached while background loading. + // Or the feature has been disabled. + if a.enabled && startTime.Unix() == a.currentGlobalSegment.startTimestamp { + for _, ent := range out.Clients { + a.globalPartialMonthClientTracker[ent.ClientID] = ent + } + } + a.globalFragmentLock.Unlock() } - out := &activity.EntityActivityLog{} - err = proto.Unmarshal(data.Value, out) - if err != nil { + + } else { + // load all the active local clients + localPath := activityLocalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(sequenceNum, 10) + out, err := a.readEntitySegmentAtPath(ctx, localPath) + if err != nil && !errors.Is(err, ErrEmptyResponse) { return err } - a.globalFragmentLock.Lock() - // Handle the (unlikely) case where the end of the month has been reached while background loading. - // Or the feature has been disabled. - if a.enabled && startTime.Unix() == a.currentGlobalSegment.startTimestamp { - for _, ent := range out.Clients { - a.globalPartialMonthClientTracker[ent.ClientID] = ent + if out != nil { + a.localFragmentLock.Lock() + // Handle the (unlikely) case where the end of the month has been reached while background loading. + // Or the feature has been disabled. + if a.enabled && startTime.Unix() == a.currentLocalSegment.startTimestamp { + for _, ent := range out.Clients { + a.partialMonthLocalClientTracker[ent.ClientID] = ent + } } + a.localFragmentLock.Unlock() } - a.globalFragmentLock.Unlock() - return nil - } - // load all the active local clients - localPath := activityLocalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(sequenceNum, 10) - data, err := a.view.Get(ctx, localPath) - if err != nil { - return err } - if data == nil { - return nil - } - out := &activity.EntityActivityLog{} - err = proto.Unmarshal(data.Value, out) - if err != nil { - return err - } - a.localFragmentLock.Lock() - // Handle the (unlikely) case where the end of the month has been reached while background loading. - // Or the feature has been disabled. - if a.enabled && startTime.Unix() == a.currentLocalSegment.startTimestamp { - for _, ent := range out.Clients { - a.partialMonthLocalClientTracker[ent.ClientID] = ent - } - } - a.localFragmentLock.Unlock() - return nil } @@ -932,23 +1031,17 @@ func (a *ActivityLog) loadPriorEntitySegment(ctx context.Context, startTime time // into memory (to append new entries), and to the globalPartialMonthClientTracker and partialMonthLocalClientTracker to // avoid duplication call with fragmentLock, globalFragmentLock, localFragmentLock and l held. func (a *ActivityLog) loadCurrentClientSegment(ctx context.Context, startTime time.Time, localSegmentSequenceNumber uint64, globalSegmentSequenceNumber uint64) error { - // load current global segment - path := activityGlobalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(globalSegmentSequenceNumber, 10) - // setting a.currentSegment timestamp to support upgrades a.currentSegment.startTimestamp = startTime.Unix() - data, err := a.view.Get(ctx, path) - if err != nil { + // load current global segment + path := activityGlobalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(globalSegmentSequenceNumber, 10) + + out, err := a.readEntitySegmentAtPath(ctx, path) + if err != nil && !errors.Is(err, ErrEmptyResponse) { return err } - if data != nil { - out := &activity.EntityActivityLog{} - err = proto.Unmarshal(data.Value, out) - if err != nil { - return err - } - + if out != nil { if !a.core.perfStandby { a.currentGlobalSegment = segmentInfo{ startTimestamp: startTime.Unix(), @@ -971,17 +1064,11 @@ func (a *ActivityLog) loadCurrentClientSegment(ctx context.Context, startTime ti // load current local segment path = activityLocalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(localSegmentSequenceNumber, 10) - data, err = a.view.Get(ctx, path) - if err != nil { + out, err = a.readEntitySegmentAtPath(ctx, path) + if err != nil && !errors.Is(err, ErrEmptyResponse) { return err } - if data != nil { - out := &activity.EntityActivityLog{} - err = proto.Unmarshal(data.Value, out) - if err != nil { - return err - } - + if out != nil { if !a.core.perfStandby { a.currentLocalSegment = segmentInfo{ startTimestamp: startTime.Unix(), @@ -998,12 +1085,43 @@ func (a *ActivityLog) loadCurrentClientSegment(ctx context.Context, startTime ti for _, client := range out.Clients { a.partialMonthLocalClientTracker[client.ClientID] = client } - } return nil } +func (a *ActivityLog) readEntitySegmentAtPath(ctx context.Context, path string) (*activity.EntityActivityLog, error) { + data, err := a.view.Get(ctx, path) + if err != nil { + return nil, err + } + if data == nil { + return nil, ErrEmptyResponse + } + out := &activity.EntityActivityLog{} + err = proto.Unmarshal(data.Value, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (a *ActivityLog) readTokenSegmentAtPath(ctx context.Context, path string) (*activity.TokenCount, error) { + data, err := a.view.Get(ctx, path) + if err != nil { + return nil, err + } + if data == nil { + return nil, ErrEmptyResponse + } + out := &activity.TokenCount{} + err = proto.Unmarshal(data.Value, out) + if err != nil { + return nil, err + } + return out, nil +} + // tokenCountExists checks if there's a token log for :startTime: // this function should be called with the lock held func (a *ActivityLog) tokenCountExists(ctx context.Context, startTime time.Time) (bool, error) { @@ -1170,6 +1288,26 @@ func (a *ActivityLog) deleteLogWorker(ctx context.Context, startTimestamp int64, close(whenDone) } +func (a *ActivityLog) deleteOldStoragePathWorker(ctx context.Context, pathPrefix string) { + pathTimes, err := a.view.List(ctx, pathPrefix) + if err != nil { + a.logger.Error("could not list segment paths", "error", err) + return + } + for _, pathTime := range pathTimes { + segments, err := a.view.List(ctx, pathPrefix+pathTime) + if err != nil { + a.logger.Error("could not list segment path", "error", err) + } + for _, seqNum := range segments { + err = a.view.Delete(ctx, pathPrefix+pathTime+seqNum) + if err != nil { + a.logger.Error("could not delete log", "error", err) + } + } + } +} + func (a *ActivityLog) WaitForDeletion() { a.l.Lock() // May be nil, if never set @@ -1508,11 +1646,78 @@ func (c *Core) setupActivityLogLocked(ctx context.Context, wg *sync.WaitGroup, r manager.retentionWorker(ctx, manager.clock.Now(), months) close(manager.retentionDone) }(manager.retentionMonths) - } + // We do not want to hold up unseal, and we need access to + // the replicationRpcClient in order for the secondary to migrate data. + // This client is only reliable preset after unseal. + c.postUnsealFuncs = append(c.postUnsealFuncs, func() { + c.activityLogMigrationTask(ctx) + }) + + } return nil } +// secondaryDuplicateClientMigrationWorker will attempt to send global data living on the +// current cluster to the primary cluster. This routine will only exit when its connected primary +// has reached version 1.19+, and this cluster has completed sending any global data that lives at the old storage paths +func (c *Core) secondaryDuplicateClientMigrationWorker(ctx context.Context) { + manager := c.activityLog + manager.logger.Trace("started secondary activity log migration worker") + storageMigrationComplete := atomic.NewBool(false) + wg := &sync.WaitGroup{} + wg.Add(1) + go func() { + if !c.IsPerfSecondary() { + // TODO: Create function for the secondary to continuously attempt to send data to the primary + } + + wg.Done() + }() + wg.Add(1) + go func() { + localClients, _, err := manager.extractLocalGlobalClientsDeprecatedStoragePath(ctx) + if err != nil { + return + } + // Store local clients at new path + for month, entitiesForMonth := range localClients { + logFragments := []*activity.LogFragment{{ + Clients: entitiesForMonth, + }} + if err = manager.savePreviousEntitySegments(ctx, month, activityLocalPathPrefix, logFragments); err != nil { + manager.logger.Error("failed to write local segment", "error", err, "month", month) + return + } + } + storageMigrationComplete.Store(true) + // TODO: generate/store PCQs for these local clients + wg.Done() + }() + wg.Wait() + if !storageMigrationComplete.Load() { + manager.logger.Error("could not complete migration of duplicate clients on cluster") + return + } + // We have completed the vital portions of the storage migration + if err := manager.writeDedupClientsUpgrade(ctx); err != nil { + manager.logger.Error("could not complete migration of duplicate clients on cluster") + return + } + + // Now that all the clients have been migrated and PCQs have been created, remove all clients at old storage paths + manager.oldStoragePathsCleaned = make(chan struct{}) + go func() { + defer close(manager.oldStoragePathsCleaned) + manager.deleteOldStoragePathWorker(ctx, activityEntityBasePath) + manager.deleteOldStoragePathWorker(ctx, activityTokenBasePath) + // TODO: Delete old PCQs + }() + + manager.dedupClientsUpgradeComplete.Store(true) + manager.logger.Trace("completed secondary activity log migration worker") +} + func (a *ActivityLog) hasRegeneratedACME(ctx context.Context) bool { regenerated, err := a.view.Get(ctx, activityACMERegenerationKey) if err != nil { @@ -1522,6 +1727,15 @@ func (a *ActivityLog) hasRegeneratedACME(ctx context.Context) bool { return regenerated != nil } +func (a *ActivityLog) hasDedupClientsUpgrade(ctx context.Context) bool { + regenerated, err := a.view.Get(ctx, activityDeduplicationUpgradeKey) + if err != nil { + a.logger.Error("unable to access deduplication regeneration key") + return false + } + return regenerated != nil +} + func (a *ActivityLog) writeRegeneratedACME(ctx context.Context) error { regeneratedEntry, err := logical.StorageEntryJSON(activityACMERegenerationKey, true) if err != nil { @@ -1530,6 +1744,14 @@ func (a *ActivityLog) writeRegeneratedACME(ctx context.Context) error { return a.view.Put(ctx, regeneratedEntry) } +func (a *ActivityLog) writeDedupClientsUpgrade(ctx context.Context) error { + regeneratedEntry, err := logical.StorageEntryJSON(activityDeduplicationUpgradeKey, true) + if err != nil { + return err + } + return a.view.Put(ctx, regeneratedEntry) +} + func (a *ActivityLog) regeneratePrecomputedQueries(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -1696,7 +1918,12 @@ func (a *ActivityLog) secondaryFragmentWorker(ctx context.Context) { <-timer.C } } - sendFunc() + // Only send data if no upgrade is in progress. Else, the active worker will + // store the data in a temporary location until it is garbage collected + if a.dedupClientsUpgradeComplete.Load() { + sendFunc() + } + case <-endOfMonth.C: a.logger.Trace("sending global fragment on end of month") // Flush the current fragment, if any @@ -1706,13 +1933,16 @@ func (a *ActivityLog) secondaryFragmentWorker(ctx context.Context) { <-timer.C } } - sendFunc() - - // clear active entity set - a.globalFragmentLock.Lock() - a.globalPartialMonthClientTracker = make(map[string]*activity.EntityRecord) - - a.globalFragmentLock.Unlock() + // If an upgrade is in progress, don't do anything + // The active fragmentWorker will take care of flushing the clients to a temporary location + if a.dedupClientsUpgradeComplete.Load() { + sendFunc() + // clear active entity set + a.globalFragmentLock.Lock() + a.globalPartialMonthClientTracker = make(map[string]*activity.EntityRecord) + + a.globalFragmentLock.Unlock() + } // Set timer for next month. // The current segment *probably* hasn't been set yet (via invalidation), @@ -3798,6 +4028,110 @@ func (a *ActivityLog) writeExport(ctx context.Context, rw http.ResponseWriter, f return nil } +func (c *Core) activityLogMigrationTask(ctx context.Context) { + manager := c.activityLog + if !c.IsPerfSecondary() { + // If the oldest version is less than 1.19 and no migrations tasks have been run, kick off the migration task + if !manager.OldestVersionHasDeduplicatedClients(ctx) && !manager.hasDedupClientsUpgrade(ctx) { + go c.primaryDuplicateClientMigrationWorker(ctx) + } else { + // Store that upgrade processes have already been completed + manager.writeDedupClientsUpgrade(ctx) + manager.dedupClientsUpgradeComplete.Store(true) + } + } else { + // We kick off the secondary migration worker in any chance that the primary has not yet upgraded. + // If we have already completed the migration task, it indicates that the cluster has completed sending data to an + // already upgraded primary + if !manager.hasDedupClientsUpgrade(ctx) { + go c.secondaryDuplicateClientMigrationWorker(ctx) + } else { + // Store that upgrade processes have already been completed + manager.writeDedupClientsUpgrade(ctx) + manager.dedupClientsUpgradeComplete.Store(true) + + } + } +} + +// primaryDuplicateClientMigrationWorker will attempt to receive global data living on the +// connected secondary clusters. Once the data has been received, it will combine it with +// its own global data at old storage paths, and migrate all of it to new storage paths on the +// current cluster. This method wil only exit once all connected secondary clusters have +// upgraded to 1.19, and this cluster receives global data from all of them. +func (c *Core) primaryDuplicateClientMigrationWorker(ctx context.Context) error { + a := c.activityLog + a.logger.Trace("started primary activity log migration worker") + + // Collect global clients from secondary + err := a.waitForSecondaryGlobalClients(ctx) + if err != nil { + return err + } + + // Get local and global entities from previous months + clusterLocalClients, clusterGlobalClients, err := a.extractLocalGlobalClientsDeprecatedStoragePath(ctx) + if err != nil { + a.logger.Error("could not extract local and global clients from storage", "error", err) + return err + } + // Get tokens from previous months at old storage paths + clusterTokens, err := a.extractTokensDeprecatedStoragePath(ctx) + + // TODO: Collect clients from secondaries into slice of fragments + + // Store global clients at new path + for month, entitiesForMonth := range clusterGlobalClients { + logFragments := []*activity.LogFragment{{ + Clients: entitiesForMonth, + }} + if err = a.savePreviousEntitySegments(ctx, month, activityGlobalPathPrefix, logFragments); err != nil { + a.logger.Error("failed to write global segment", "error", err, "month", month) + return err + } + } + // Store local clients at new path + for month, entitiesForMonth := range clusterLocalClients { + logFragments := []*activity.LogFragment{{ + Clients: entitiesForMonth, + }} + if err = a.savePreviousEntitySegments(ctx, month, activityLocalPathPrefix, logFragments); err != nil { + a.logger.Error("failed to write local segment", "error", err, "month", month) + return err + } + } + // Store tokens at new path + for month, tokenCount := range clusterTokens { + // Combine all token counts from all clusters + logFragments := make([]*activity.LogFragment, len(tokenCount)) + for i, tokens := range tokenCount { + logFragments[i] = &activity.LogFragment{NonEntityTokens: tokens} + } + if err = a.savePreviousTokenSegments(ctx, month, activityLocalPathPrefix+activityTokenBasePath, logFragments); err != nil { + a.logger.Error("failed to write token segment", "error", err, "month", month) + return err + } + } + + // TODO: After data has been migrated to new locations, we will regenerate all the global and local PCQs + + if err := a.writeDedupClientsUpgrade(ctx); err != nil { + a.logger.Error("could not complete migration of duplicate clients on cluster") + return err + } + // Garbage collect data at old paths + a.oldStoragePathsCleaned = make(chan struct{}) + go func() { + defer close(a.oldStoragePathsCleaned) + a.deleteOldStoragePathWorker(ctx, activityEntityBasePath) + a.deleteOldStoragePathWorker(ctx, activityTokenBasePath) + // We will also need to delete old PCQs + }() + a.dedupClientsUpgradeComplete.Store(true) + a.logger.Trace("completed primary activity log migration worker") + return nil +} + type encoder interface { Encode(*ActivityLogExportRecord) error Flush() diff --git a/vault/activity_log_stubs_oss.go b/vault/activity_log_stubs_oss.go index 7d2457360563..e7115d41e475 100644 --- a/vault/activity_log_stubs_oss.go +++ b/vault/activity_log_stubs_oss.go @@ -5,11 +5,22 @@ package vault -import "context" +import ( + "context" + "errors" +) //go:generate go run github.com/hashicorp/vault/tools/stubmaker +// ErrEmptyResponse error is used to avoid returning "nil, nil" from a function +var ErrEmptyResponse = errors.New("empty response; the system encountered a statement that exclusively returns nil values") + // sendGlobalClients is a no-op on CE func (a *ActivityLog) sendGlobalClients(ctx context.Context) error { return nil } + +// waitForSecondaryGlobalClients is a no-op on CE +func (a *ActivityLog) waitForSecondaryGlobalClients(ctx context.Context) error { + return nil +} diff --git a/vault/activity_log_test.go b/vault/activity_log_test.go index 1f36a7856582..8599592d8007 100644 --- a/vault/activity_log_test.go +++ b/vault/activity_log_test.go @@ -5821,3 +5821,280 @@ func TestCreateSegment_StoreSegment(t *testing.T) { }) } } + +// TestActivityLog_PrimaryDuplicateClientMigrationWorker verifies that the primary +// migration worker correctly moves data from old location to the new location +func TestActivityLog_PrimaryDuplicateClientMigrationWorker(t *testing.T) { + cluster := NewTestCluster(t, nil, nil) + core := cluster.Cores[0].Core + a := core.activityLog + a.SetEnable(true) + + ctx := context.Background() + timeStamp := time.Now() + startOfMonth := timeutil.StartOfMonth(timeStamp) + oneMonthAgo := timeutil.StartOfPreviousMonth(timeStamp) + twoMonthsAgo := timeutil.StartOfPreviousMonth(oneMonthAgo) + + clientRecordsGlobal := make([]*activity.EntityRecord, ActivitySegmentClientCapacity*2+1) + for i := range clientRecordsGlobal { + clientRecordsGlobal[i] = &activity.EntityRecord{ + ClientID: fmt.Sprintf("111122222-3333-4444-5555-%012v", i), + Timestamp: timeStamp.Unix(), + NonEntity: false, + } + } + clientRecordsLocal := make([]*activity.EntityRecord, ActivitySegmentClientCapacity*2+1) + for i := range clientRecordsGlobal { + clientRecordsLocal[i] = &activity.EntityRecord{ + ClientID: fmt.Sprintf("011122222-3333-4444-5555-%012v", i), + Timestamp: timeStamp.Unix(), + // This is to trick the system into believing this a local client when parsing data + ClientType: nonEntityTokenActivityType, + } + } + + tokenCounts := map[string]uint64{ + "ns1": 10, + "ns2": 11, + "ns3": 12, + } + + // Write global and local clients to old path + a.savePreviousEntitySegments(ctx, twoMonthsAgo.Unix(), "", []*activity.LogFragment{{Clients: append(clientRecordsLocal, clientRecordsGlobal...)}}) + a.savePreviousEntitySegments(ctx, oneMonthAgo.Unix(), "", []*activity.LogFragment{{Clients: append(clientRecordsLocal[1:], clientRecordsGlobal[1:]...)}}) + a.savePreviousEntitySegments(ctx, startOfMonth.Unix(), "", []*activity.LogFragment{{Clients: append(clientRecordsLocal[2:], clientRecordsGlobal[2:]...)}}) + + // Assert that the migration workers have not been run + require.True(t, a.hasDedupClientsUpgrade(ctx)) + require.True(t, a.dedupClientsUpgradeComplete.Load()) + + // Resetting this to false so that we can + // verify that after the migrations is completed, the correct values have been stored + a.dedupClientsUpgradeComplete.Store(false) + require.NoError(t, a.view.Delete(ctx, activityDeduplicationUpgradeKey)) + + // Forcefully run the primary migration worker + core.primaryDuplicateClientMigrationWorker(ctx) + + // Verify that we have the correct number of global clients at the new storage paths + times := []time.Time{twoMonthsAgo, oneMonthAgo, startOfMonth} + for index, time := range times { + reader, err := a.NewSegmentFileReader(ctx, time) + require.NoError(t, err) + globalClients := make([]*activity.EntityRecord, 0) + for { + segment, err := reader.ReadGlobalEntity(ctx) + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + globalClients = append(globalClients, segment.GetClients()...) + } + require.Equal(t, len(clientRecordsGlobal)-index, len(globalClients)) + } + + // Verify local clients + for index, time := range times { + reader, err := a.NewSegmentFileReader(ctx, time) + require.NoError(t, err) + localClients := make([]*activity.EntityRecord, 0) + for { + segment, err := reader.ReadLocalEntity(ctx) + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + localClients = append(localClients, segment.GetClients()...) + } + require.Equal(t, len(clientRecordsLocal)-index, len(localClients)) + } + + // Verify non-entity tokens have been correctly migrated + for _, time := range times { + reader, err := a.NewSegmentFileReader(ctx, time) + require.NoError(t, err) + for { + segment, err := reader.ReadToken(ctx) + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + // Verify that the data is correct + deep.Equal(segment.GetCountByNamespaceID(), tokenCounts) + } + } + + // Check that the storage key has been updated + require.True(t, a.hasDedupClientsUpgrade(ctx)) + // Check that the bool has been updated + require.True(t, a.dedupClientsUpgradeComplete.Load()) + + // Wait for the deletion of old logs to complete + timeout := time.After(25 * time.Second) + // Wait for channel indicating deletion to be written + select { + case <-timeout: + t.Fatal("timed out waiting for deletion to complete") + case <-a.oldStoragePathsCleaned: + break + } + + // Verify there is no data at the old paths + times, err := a.availableTimesAtPath(ctx, time.Now(), activityEntityBasePath) + require.NoError(t, err) + require.Equal(t, 0, len(times)) + + // Verify there is no data at the old token paths + times, err = a.availableTimesAtPath(ctx, time.Now(), activityTokenBasePath) + require.NoError(t, err) + require.Equal(t, 0, len(times)) +} + +// TestActivityLog_SecondaryDuplicateClientMigrationWorker verifies that the secondary +// migration worker correctly moves local data from old location to the new location +func TestActivityLog_SecondaryDuplicateClientMigrationWorker(t *testing.T) { + cluster := NewTestCluster(t, nil, nil) + core := cluster.Cores[0].Core + a := core.activityLog + a.SetEnable(true) + + ctx := context.Background() + timeStamp := time.Now() + startOfMonth := timeutil.StartOfMonth(timeStamp) + oneMonthAgo := timeutil.StartOfPreviousMonth(timeStamp) + twoMonthsAgo := timeutil.StartOfPreviousMonth(oneMonthAgo) + + clientRecordsGlobal := make([]*activity.EntityRecord, ActivitySegmentClientCapacity*2+1) + for i := range clientRecordsGlobal { + clientRecordsGlobal[i] = &activity.EntityRecord{ + ClientID: fmt.Sprintf("111122222-3333-4444-5555-%012v", i), + Timestamp: timeStamp.Unix(), + NonEntity: false, + } + } + clientRecordsLocal := make([]*activity.EntityRecord, ActivitySegmentClientCapacity*2+1) + for i := range clientRecordsGlobal { + clientRecordsLocal[i] = &activity.EntityRecord{ + ClientID: fmt.Sprintf("011122222-3333-4444-5555-%012v", i), + Timestamp: timeStamp.Unix(), + // This is to trick the system into believing this a local client when parsing data + ClientType: nonEntityTokenActivityType, + } + } + + tokenCounts := map[string]uint64{ + "ns1": 10, + "ns2": 11, + "ns3": 12, + } + + // Write global and local clients to old path + a.savePreviousEntitySegments(ctx, twoMonthsAgo.Unix(), "", []*activity.LogFragment{{Clients: append(clientRecordsLocal, clientRecordsGlobal...)}}) + a.savePreviousEntitySegments(ctx, oneMonthAgo.Unix(), "", []*activity.LogFragment{{Clients: append(clientRecordsLocal[1:], clientRecordsGlobal[1:]...)}}) + a.savePreviousEntitySegments(ctx, startOfMonth.Unix(), "", []*activity.LogFragment{{Clients: append(clientRecordsLocal[2:], clientRecordsGlobal[2:]...)}}) + + // Write tokens to old path + a.savePreviousTokenSegments(ctx, twoMonthsAgo.Unix(), "", []*activity.LogFragment{{NonEntityTokens: tokenCounts}}) + a.savePreviousTokenSegments(ctx, oneMonthAgo.Unix(), "", []*activity.LogFragment{{NonEntityTokens: tokenCounts}}) + a.savePreviousTokenSegments(ctx, startOfMonth.Unix(), "", []*activity.LogFragment{{NonEntityTokens: tokenCounts}}) + + // Assert that the migration workers have not been run + require.True(t, a.hasDedupClientsUpgrade(ctx)) + require.True(t, a.dedupClientsUpgradeComplete.Load()) + + // Resetting this to false so that we can + // verify that after the migrations is completed, the correct values have been stored + a.dedupClientsUpgradeComplete.Store(false) + require.NoError(t, a.view.Delete(ctx, activityDeduplicationUpgradeKey)) + + // Forcefully run the secondary migration worker + core.secondaryDuplicateClientMigrationWorker(ctx) + + // Wait for the storage migration to complete + ticker := time.NewTicker(100 * time.Millisecond) + timeout := time.After(25 * time.Second) + for { + select { + case <-timeout: + t.Fatal("timed out waiting for migration to complete") + case <-ticker.C: + } + if a.dedupClientsUpgradeComplete.Load() { + break + } + } + + // Verify that no global clients have been migrated + times := []time.Time{twoMonthsAgo, oneMonthAgo, startOfMonth} + for _, time := range times { + reader, err := a.NewSegmentFileReader(ctx, time) + require.NoError(t, err) + globalClients := make([]*activity.EntityRecord, 0) + for { + segment, err := reader.ReadGlobalEntity(ctx) + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + globalClients = append(globalClients, segment.GetClients()...) + } + require.Equal(t, 0, len(globalClients)) + } + + // Verify local clients have been correctly migrated + for index, time := range times { + reader, err := a.NewSegmentFileReader(ctx, time) + require.NoError(t, err) + localClients := make([]*activity.EntityRecord, 0) + for { + segment, err := reader.ReadLocalEntity(ctx) + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + localClients = append(localClients, segment.GetClients()...) + } + require.Equal(t, len(clientRecordsLocal)-index, len(localClients)) + } + + // Verify non-entity tokens have been correctly migrated + for _, time := range times { + reader, err := a.NewSegmentFileReader(ctx, time) + require.NoError(t, err) + for { + segment, err := reader.ReadToken(ctx) + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + // Verify that the data is correct + deep.Equal(segment.GetCountByNamespaceID(), tokenCounts) + } + } + + // Check that the storage key has been updated + require.True(t, a.hasDedupClientsUpgrade(ctx)) + // Check that the bool has been updated + require.True(t, a.dedupClientsUpgradeComplete.Load()) + + // Wait for the deletion of old logs to complete + timeout = time.After(25 * time.Second) + // Wait for channel indicating deletion to be written + select { + case <-timeout: + t.Fatal("timed out waiting for deletion to complete") + case <-a.oldStoragePathsCleaned: + break + } + + // Verify there is no data at the old entity paths + times, err := a.availableTimesAtPath(ctx, time.Now(), activityEntityBasePath) + require.NoError(t, err) + require.Equal(t, 0, len(times)) + + // Verify there is no data at the old token paths + times, err = a.availableTimesAtPath(ctx, time.Now(), activityTokenBasePath) + require.NoError(t, err) + require.Equal(t, 0, len(times)) +} diff --git a/vault/activity_log_util_common.go b/vault/activity_log_util_common.go index f3cd616ed99a..86c824adebab 100644 --- a/vault/activity_log_util_common.go +++ b/vault/activity_log_util_common.go @@ -14,6 +14,7 @@ import ( "time" "github.com/axiomhq/hyperloglog" + semver "github.com/hashicorp/go-version" "github.com/hashicorp/vault/helper/timeutil" "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/vault/activity" @@ -552,3 +553,98 @@ func (a *ActivityLog) namespaceRecordToCountsResponse(record *activity.Namespace ACMEClients: int(record.ACMEClients), } } + +func (a *ActivityLog) extractLocalGlobalClientsDeprecatedStoragePath(ctx context.Context) (map[int64][]*activity.EntityRecord, map[int64][]*activity.EntityRecord, error) { + clusterGlobalClients := make(map[int64][]*activity.EntityRecord) + clusterLocalClients := make(map[int64][]*activity.EntityRecord) + + // Extract global clients on the current cluster per month store them in a map + times, err := a.availableTimesAtPath(ctx, time.Now(), activityEntityBasePath) + if err != nil { + a.logger.Error("could not list available logs until now") + return clusterLocalClients, clusterGlobalClients, fmt.Errorf("could not list available logs on the cluster") + } + for _, time := range times { + entityPath := activityEntityBasePath + fmt.Sprint(time.Unix()) + "/" + segmentPaths, err := a.view.List(ctx, entityPath) + if err != nil { + return nil, nil, err + } + for _, seqNumber := range segmentPaths { + segment, err := a.readEntitySegmentAtPath(ctx, entityPath+seqNumber) + if segment == nil { + continue + } + if err != nil { + a.logger.Warn("failed to read segment", "error", err) + return clusterLocalClients, clusterGlobalClients, err + } + for _, entity := range segment.GetClients() { + // If the client is not local, then add it to a map + if local, _ := a.isClientLocal(entity); !local { + if _, ok := clusterGlobalClients[time.Unix()]; !ok { + clusterGlobalClients[time.Unix()] = make([]*activity.EntityRecord, 0) + } + clusterGlobalClients[time.Unix()] = append(clusterGlobalClients[time.Unix()], entity) + } else { + if _, ok := clusterLocalClients[time.Unix()]; !ok { + clusterLocalClients[time.Unix()] = make([]*activity.EntityRecord, 0) + } + clusterLocalClients[time.Unix()] = append(clusterLocalClients[time.Unix()], entity) + } + } + } + } + + return clusterLocalClients, clusterGlobalClients, nil +} + +func (a *ActivityLog) extractTokensDeprecatedStoragePath(ctx context.Context) (map[int64][]map[string]uint64, error) { + tokensByMonth := make(map[int64][]map[string]uint64) + times, err := a.availableTimesAtPath(ctx, time.Now(), activityTokenBasePath) + if err != nil { + return nil, err + } + for _, monthTime := range times { + tokenPath := activityTokenBasePath + fmt.Sprint(monthTime.Unix()) + "/" + segmentPaths, err := a.view.List(ctx, tokenPath) + if err != nil { + return nil, err + } + tokensByMonth[monthTime.Unix()] = make([]map[string]uint64, 0) + for _, seqNum := range segmentPaths { + tokenCount, err := a.readTokenSegmentAtPath(ctx, tokenPath+seqNum) + if tokenCount == nil { + a.logger.Error("data at path has been unexpectedly deleted", "path", tokenPath+seqNum) + continue + } + if err != nil { + return nil, err + } + tokensByMonth[monthTime.Unix()] = append(tokensByMonth[monthTime.Unix()], tokenCount.CountByNamespaceID) + } + } + return tokensByMonth, nil +} + +// OldestVersionHasDeduplicatedClients returns whether this cluster is 1.19+, and +// hence supports deduplicated clients +func (a *ActivityLog) OldestVersionHasDeduplicatedClients(ctx context.Context) bool { + oldestVersionIsDedupClients := a.core.IsNewInstall(ctx) + if !oldestVersionIsDedupClients { + if v, _, err := a.core.FindOldestVersionTimestamp(); err == nil { + oldestVersion, err := semver.NewSemver(v) + if err != nil { + a.core.logger.Debug("could not extract version instance", "version", v) + return false + } + dedupChangeVersion, err := semver.NewSemver(DeduplicatedClientMinimumVersion) + if err != nil { + a.core.logger.Debug("could not extract version instance", "version", DeduplicatedClientMinimumVersion) + return false + } + oldestVersionIsDedupClients = oldestVersionIsDedupClients || oldestVersion.GreaterThanOrEqual(dedupChangeVersion) + } + } + return oldestVersionIsDedupClients +} diff --git a/vault/activity_log_util_common_test.go b/vault/activity_log_util_common_test.go index f84775da3fc2..2d0a0c4ceee2 100644 --- a/vault/activity_log_util_common_test.go +++ b/vault/activity_log_util_common_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/axiomhq/hyperloglog" + "github.com/go-test/deep" "github.com/hashicorp/vault/helper/timeutil" "github.com/hashicorp/vault/vault/activity" "github.com/stretchr/testify/require" @@ -1014,6 +1015,14 @@ func writeTokenSegment(t *testing.T, core *Core, ts time.Time, index int, item * WriteToStorage(t, core, makeSegmentPath(t, activityTokenLocalBasePath, ts, index), protoItem) } +// writeTokenSegmentOldPath writes a single segment file with the given time and index for a token at the old path +func writeTokenSegmentOldPath(t *testing.T, core *Core, ts time.Time, index int, item *activity.TokenCount) { + t.Helper() + protoItem, err := proto.Marshal(item) + require.NoError(t, err) + WriteToStorage(t, core, makeSegmentPath(t, activityTokenBasePath, ts, index), protoItem) +} + // makeSegmentPath formats the path for a segment at a particular time and index func makeSegmentPath(t *testing.T, typ string, ts time.Time, index int) string { t.Helper() @@ -1213,3 +1222,50 @@ func TestSegmentFileReader(t *testing.T) { require.True(t, proto.Equal(gotTokens[i], tokens[i])) } } + +// TestExtractTokens_OldStoragePaths verifies that the correct tokens are extracted +// from the old token paths in storage. These old storage paths were used in <=1.9 to +// store tokens without clientIds (non-entity tokens). +func TestExtractTokens_OldStoragePaths(t *testing.T) { + core, _, _ := TestCoreUnsealed(t) + now := time.Now() + + // write token at index 3 + token := &activity.TokenCount{CountByNamespaceID: map[string]uint64{ + "ns": 10, + "ns3": 1, + "ns1": 2, + }} + + lastMonth := timeutil.StartOfPreviousMonth(now) + twoMonthsAgo := timeutil.StartOfPreviousMonth(lastMonth) + + thisMonthData := []map[string]uint64{token.CountByNamespaceID, token.CountByNamespaceID} + lastMonthData := []map[string]uint64{token.CountByNamespaceID, token.CountByNamespaceID, token.CountByNamespaceID, token.CountByNamespaceID} + twoMonthsAgoData := []map[string]uint64{token.CountByNamespaceID} + + expected := map[int64][]map[string]uint64{ + now.Unix(): thisMonthData, + lastMonth.Unix(): lastMonthData, + twoMonthsAgo.Unix(): twoMonthsAgoData, + } + + // This month's token data is at broken segment sequences + writeTokenSegmentOldPath(t, core, now, 1, token) + writeTokenSegmentOldPath(t, core, now, 3, token) + // Last months token data is at normal segment sequences + writeTokenSegmentOldPath(t, core, lastMonth, 0, token) + writeTokenSegmentOldPath(t, core, lastMonth, 1, token) + writeTokenSegmentOldPath(t, core, lastMonth, 2, token) + writeTokenSegmentOldPath(t, core, lastMonth, 3, token) + // Month before is at only one random segment sequence + writeTokenSegmentOldPath(t, core, twoMonthsAgo, 2, token) + + tokens, err := core.activityLog.extractTokensDeprecatedStoragePath(context.Background()) + require.NoError(t, err) + require.Equal(t, 3, len(tokens)) + + if diff := deep.Equal(expected, tokens); diff != nil { + t.Fatal(diff) + } +} From 21b0e5ad50ac4c18b2951f1fa0c64b7dc5db9feb Mon Sep 17 00:00:00 2001 From: Violet Hynes Date: Tue, 3 Dec 2024 15:41:43 -0500 Subject: [PATCH 13/45] VAULT-32158 docs (#29058) --- .../license/product-usage-reporting.mdx | 138 ++++++++++-------- 1 file changed, 74 insertions(+), 64 deletions(-) diff --git a/website/content/docs/enterprise/license/product-usage-reporting.mdx b/website/content/docs/enterprise/license/product-usage-reporting.mdx index 566e795eee9b..1b855f11f128 100644 --- a/website/content/docs/enterprise/license/product-usage-reporting.mdx +++ b/website/content/docs/enterprise/license/product-usage-reporting.mdx @@ -106,70 +106,80 @@ HashiCorp collects the following product usage metrics as part of the `metrics` [JSON payload that it collects for licence utilization](/vault/docs/enterprise/license/utilization-reporting#example-payloads). All of these metrics are numerical, and contain no sensitive values or additional metadata: -| Metric Name | Description | -|---------------------------------------------|--------------------------------------------------------------------------| -| `vault.namespaces.count` | Total number of namespaces. | -| `vault.leases.count` | Total number of leases within Vault. | -| `vault.quotas.ratelimit.count` | Total number of rate limit quotas within Vault. | -| `vault.quotas.leasecount.count` | Total number of lease count quotas within Vault. | -| `vault.kv.version1.secrets.count` | Total number of KVv1 secrets within Vault. | -| `vault.kv.version2.secrets.count` | Total number of KVv2 secrets within Vault. | -| `vault.kv.version1.secrets.namespace.max` | The highest number of KVv1 secrets in a namespace in Vault, e.g. `1000`. | -| `vault.kv.version2.secrets.namespace.max` | The highest number of KVv2 secrets in a namespace in Vault, e.g. `1000`. | -| `vault.kv.version1.secrets.namespace.min` | The lowest number of KVv1 secrets in a namespace in Vault, e.g. `2`. | -| `vault.kv.version2.secrets.namespace.min` | The highest number of KVv2 secrets in a namespace in Vault, e.g. `1000`. | -| `vault.kv.version1.secrets.namespace.mean` | The mean number of KVv1 secrets in namespaces in Vault, e.g. `52.8`. | -| `vault.kv.version2.secrets.namespace.mean` | The mean number of KVv2 secrets in namespaces in Vault, e.g. `52.8`. | -| `vault.auth.method.approle.count` | The total number of Approle auth mounts in Vault. | -| `vault.auth.method.alicloud.count` | The total number of Alicloud auth mounts in Vault. | -| `vault.auth.method.aws.count` | The total number of AWS auth mounts in Vault. | -| `vault.auth.method.appid.count` | The total number of App ID auth mounts in Vault. | -| `vault.auth.method.azure.count` | The total number of Azure auth mounts in Vault. | -| `vault.auth.method.cloudfoundry.count` | The total number of Cloud Foundry auth mounts in Vault. | -| `vault.auth.method.github.count` | The total number of GitHub auth mounts in Vault. | -| `vault.auth.method.gcp.count` | The total number of GCP auth mounts in Vault. | -| `vault.auth.method.jwt.count` | The total number of JWT auth mounts in Vault. | -| `vault.auth.method.kerberos.count` | The total number of Kerberos auth mounts in Vault. | -| `vault.auth.method.kubernetes.count` | The total number of kubernetes auth mounts in Vault. | -| `vault.auth.method.ldap.count` | The total number of LDAP auth mounts in Vault. | -| `vault.auth.method.oci.count` | The total number of OCI auth mounts in Vault. | -| `vault.auth.method.okta.count` | The total number of Okta auth mounts in Vault. | -| `vault.auth.method.pcf.count` | The total number of PCF auth mounts in Vault. | -| `vault.auth.method.radius.count` | The total number of Radius auth mounts in Vault. | -| `vault.auth.method.saml.count` | The total number of SAML auth mounts in Vault. | -| `vault.auth.method.cert.count` | The total number of Cert auth mounts in Vault. | -| `vault.auth.method.oidc.count` | The total number of OIDC auth mounts in Vault. | -| `vault.auth.method.token.count` | The total number of Token auth mounts in Vault. | -| `vault.auth.method.userpass.count` | The total number of Userpass auth mounts in Vault. | -| `vault.auth.method.plugin.count` | The total number of custom plugin auth mounts in Vault. | -| `vault.secret.engine.activedirectory.count` | The total number of Active Directory secret engines in Vault. | -| `vault.secret.engine.alicloud.count` | The total number of Alicloud secret engines in Vault. | -| `vault.secret.engine.aws.count` | The total number of AWS secret engines in Vault. | -| `vault.secret.engine.azure.count` | The total number of Azure secret engines in Vault. | -| `vault.secret.engine.consul.count` | The total number of Consul secret engines in Vault. | -| `vault.secret.engine.gcp.count` | The total number of GCP secret engines in Vault. | -| `vault.secret.engine.gcpkms.count` | The total number of GCPKMS secret engines in Vault. | -| `vault.secret.engine.kubernetes.count` | The total number of Kubernetes secret engines in Vault. | -| `vault.secret.engine.cassandra.count` | The total number of Cassandra secret engines in Vault. | -| `vault.secret.engine.keymgmt.count` | The total number of Keymgmt secret engines in Vault. | -| `vault.secret.engine.kv.count` | The total number of kv secret engines in Vault. | -| `vault.secret.engine.kmip.count` | The total number of KMIP secret engines in Vault. | -| `vault.secret.engine.mongodb.count` | The total number of MongoDB secret engines in Vault. | -| `vault.secret.engine.mongodbatlas.count` | The total number of MongoDBAtlas secret engines in Vault. | -| `vault.secret.engine.mssql.count` | The total number of MSSql secret engines in Vault. | -| `vault.secret.engine.postgresql.count` | The total number of Postgresql secret engines in Vault. | -| `vault.secret.engine.nomad.count` | The total number of Nomad secret engines in Vault. | -| `vault.secret.engine.ldap.count` | The total number of LDAP secret engines in Vault. | -| `vault.secret.engine.openldap.count` | The total number of OpenLDAP secret engines in Vault. | -| `vault.secret.engine.pki.count` | The total number of PKI secret engines in Vault. | -| `vault.secret.engine.rabbitmq.count` | The total number of RabbitMQ secret engines in Vault. | -| `vault.secret.engine.ssh.count` | The total number of SSH secret engines in Vault. | -| `vault.secret.engine.terraform.count` | The total number of Terraform secret engines in Vault. | -| `vault.secret.engine.totp.count` | The total number of TOTP secret engines in Vault. | -| `vault.secret.engine.transform.count` | The total number of Transform secret engines in Vault. | -| `vault.secret.engine.transit.count` | The total number of Transit secret engines in Vault. | -| `vault.secret.engine.database.count` | The total number of Database secret engines in Vault. | -| `vault.secret.engine.plugin.count` | The total number of custom plugin secret engines in Vault. | +| Metric Name | Description | +|------------------------------------------------------|------------------------------------------------------------------------------------| +| `vault.namespaces.count` | Total number of namespaces. | +| `vault.leases.count` | Total number of leases within Vault. | +| `vault.quotas.ratelimit.count` | Total number of rate limit quotas within Vault. | +| `vault.quotas.leasecount.count` | Total number of lease count quotas within Vault. | +| `vault.kv.version1.secrets.count` | Total number of KVv1 secrets within Vault. | +| `vault.kv.version2.secrets.count` | Total number of KVv2 secrets within Vault. | +| `vault.kv.version1.secrets.namespace.max` | The highest number of KVv1 secrets in a namespace in Vault, e.g. `1000`. | +| `vault.kv.version2.secrets.namespace.max` | The highest number of KVv2 secrets in a namespace in Vault, e.g. `1000`. | +| `vault.kv.version1.secrets.namespace.min` | The lowest number of KVv1 secrets in a namespace in Vault, e.g. `2`. | +| `vault.kv.version2.secrets.namespace.min` | The highest number of KVv2 secrets in a namespace in Vault, e.g. `1000`. | +| `vault.kv.version1.secrets.namespace.mean` | The mean number of KVv1 secrets in namespaces in Vault, e.g. `52.8`. | +| `vault.kv.version2.secrets.namespace.mean` | The mean number of KVv2 secrets in namespaces in Vault, e.g. `52.8`. | +| `vault.auth.method.approle.count` | The total number of Approle auth mounts in Vault. | +| `vault.auth.method.alicloud.count` | The total number of Alicloud auth mounts in Vault. | +| `vault.auth.method.aws.count` | The total number of AWS auth mounts in Vault. | +| `vault.auth.method.appid.count` | The total number of App ID auth mounts in Vault. | +| `vault.auth.method.azure.count` | The total number of Azure auth mounts in Vault. | +| `vault.auth.method.cloudfoundry.count` | The total number of Cloud Foundry auth mounts in Vault. | +| `vault.auth.method.github.count` | The total number of GitHub auth mounts in Vault. | +| `vault.auth.method.gcp.count` | The total number of GCP auth mounts in Vault. | +| `vault.auth.method.jwt.count` | The total number of JWT auth mounts in Vault. | +| `vault.auth.method.kerberos.count` | The total number of Kerberos auth mounts in Vault. | +| `vault.auth.method.kubernetes.count` | The total number of kubernetes auth mounts in Vault. | +| `vault.auth.method.ldap.count` | The total number of LDAP auth mounts in Vault. | +| `vault.auth.method.oci.count` | The total number of OCI auth mounts in Vault. | +| `vault.auth.method.okta.count` | The total number of Okta auth mounts in Vault. | +| `vault.auth.method.pcf.count` | The total number of PCF auth mounts in Vault. | +| `vault.auth.method.radius.count` | The total number of Radius auth mounts in Vault. | +| `vault.auth.method.saml.count` | The total number of SAML auth mounts in Vault. | +| `vault.auth.method.cert.count` | The total number of Cert auth mounts in Vault. | +| `vault.auth.method.oidc.count` | The total number of OIDC auth mounts in Vault. | +| `vault.auth.method.token.count` | The total number of Token auth mounts in Vault. | +| `vault.auth.method.userpass.count` | The total number of Userpass auth mounts in Vault. | +| `vault.auth.method.plugin.count` | The total number of custom plugin auth mounts in Vault. | +| `vault.secret.engine.activedirectory.count` | The total number of Active Directory secret engines in Vault. | +| `vault.secret.engine.alicloud.count` | The total number of Alicloud secret engines in Vault. | +| `vault.secret.engine.aws.count` | The total number of AWS secret engines in Vault. | +| `vault.secret.engine.azure.count` | The total number of Azure secret engines in Vault. | +| `vault.secret.engine.consul.count` | The total number of Consul secret engines in Vault. | +| `vault.secret.engine.gcp.count` | The total number of GCP secret engines in Vault. | +| `vault.secret.engine.gcpkms.count` | The total number of GCPKMS secret engines in Vault. | +| `vault.secret.engine.kubernetes.count` | The total number of Kubernetes secret engines in Vault. | +| `vault.secret.engine.cassandra.count` | The total number of Cassandra secret engines in Vault. | +| `vault.secret.engine.keymgmt.count` | The total number of Keymgmt secret engines in Vault. | +| `vault.secret.engine.kv.count` | The total number of kv secret engines in Vault. | +| `vault.secret.engine.kmip.count` | The total number of KMIP secret engines in Vault. | +| `vault.secret.engine.mongodb.count` | The total number of MongoDB secret engines in Vault. | +| `vault.secret.engine.mongodbatlas.count` | The total number of MongoDBAtlas secret engines in Vault. | +| `vault.secret.engine.mssql.count` | The total number of MSSql secret engines in Vault. | +| `vault.secret.engine.postgresql.count` | The total number of Postgresql secret engines in Vault. | +| `vault.secret.engine.nomad.count` | The total number of Nomad secret engines in Vault. | +| `vault.secret.engine.ldap.count` | The total number of LDAP secret engines in Vault. | +| `vault.secret.engine.openldap.count` | The total number of OpenLDAP secret engines in Vault. | +| `vault.secret.engine.pki.count` | The total number of PKI secret engines in Vault. | +| `vault.secret.engine.rabbitmq.count` | The total number of RabbitMQ secret engines in Vault. | +| `vault.secret.engine.ssh.count` | The total number of SSH secret engines in Vault. | +| `vault.secret.engine.terraform.count` | The total number of Terraform secret engines in Vault. | +| `vault.secret.engine.totp.count` | The total number of TOTP secret engines in Vault. | +| `vault.secret.engine.transform.count` | The total number of Transform secret engines in Vault. | +| `vault.secret.engine.transit.count` | The total number of Transit secret engines in Vault. | +| `vault.secret.engine.database.count` | The total number of Database secret engines in Vault. | +| `vault.secret.engine.plugin.count` | The total number of custom plugin secret engines in Vault. | +| `vault.secretsync.sources.count` | The total number of secret sources configured for secret sync. | +| `vault.secretsync.destinations.count` | The total number of secret destinations configured for secret sync. | +| `vault.secretsync.destinations.aws-sm.count` | The total number of AWS-SM secret destinations configured for secret sync. | +| `vault.secretsync.destinations.azure-kv.count` | The total number of Azure-KV secret destinations configured for secret sync. | +| `vault.secretsync.destinations.gh.count` | The total number of GH secret destinations configured for secret sync. | +| `vault.secretsync.destinations.vault.count` | The total number of Vault secret destinations configured for secret sync. | +| `vault.secretsync.destinations.vercel-project.count` | The total number of Vercel Project secret destinations configured for secret sync. | +| `vault.secretsync.destinations.terraform.count` | The total number of Terraform secret destinations configured for secret sync. | +| `vault.secretsync.destinations.gitlab.count` | The total number of GitLab secret destinations configured for secret sync. | +| `vault.secretsync.destinations.inmem.count` | The total number of InMem secret destinations configured for secret sync. | ## Usage metadata list From 6cf6f16c1277c9fa9b91db60c0c2a79b9f78ed21 Mon Sep 17 00:00:00 2001 From: Sarah Chavis <62406755+schavis@users.noreply.github.com> Date: Tue, 3 Dec 2024 13:17:55 -0800 Subject: [PATCH 14/45] Make EDU owner for files under website/ (#29078) --- CODEOWNERS | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b1b876846c7a..a835da67d365 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -30,12 +30,12 @@ /plugins/ @hashicorp/vault-ecosystem /vault/plugin_catalog.go @hashicorp/vault-ecosystem -/website/content/ @hashicorp/vault-education-approvers -/website/content/docs/plugin-portal.mdx @hashicorp/vault-education-approvers +# Content on developer.hashicorp.com +/website/ @hashicorp/vault-education-approvers # Plugin docs -/website/content/docs/plugins/ @hashicorp/vault-ecosystem @hashicorp/vault-education-approvers -/website/content/docs/upgrading/plugins.mdx @hashicorp/vault-ecosystem @hashicorp/vault-education-approvers +/website/content/docs/plugins/ @hashicorp/vault-ecosystem +/website/content/docs/upgrading/plugins.mdx @hashicorp/vault-ecosystem /ui/ @hashicorp/vault-ui # UI code related to Vault's JWT/OIDC auth method and OIDC provider. From 826d2be5b33c8675a88a5719cd7e809676ab0187 Mon Sep 17 00:00:00 2001 From: Sarah Chavis <62406755+schavis@users.noreply.github.com> Date: Tue, 3 Dec 2024 13:18:53 -0800 Subject: [PATCH 15/45] [DOCS] SEO updates for Auth pages (#29070) * save * SEO updates for auth pages * tweak nav titles and other small corrections --- .../docs/auth/approle/approle-pattern.mdx | 7 +++--- website/content/docs/auth/approle/index.mdx | 10 ++++---- website/content/docs/auth/jwt/index.mdx | 7 +++--- .../docs/auth/jwt/oidc-providers/adfs.mdx | 4 ++-- .../docs/auth/jwt/oidc-providers/auth0.mdx | 7 +++--- .../docs/auth/jwt/oidc-providers/azuread.mdx | 7 +++--- .../auth/jwt/oidc-providers/forgerock.mdx | 7 +++--- .../docs/auth/jwt/oidc-providers/gitlab.mdx | 7 +++--- .../docs/auth/jwt/oidc-providers/google.mdx | 7 +++--- .../docs/auth/jwt/oidc-providers/ibmisam.mdx | 14 ++++++----- .../docs/auth/jwt/oidc-providers/index.mdx | 11 ++++----- .../docs/auth/jwt/oidc-providers/keycloak.mdx | 7 +++--- .../auth/jwt/oidc-providers/kubernetes.mdx | 7 +++--- .../docs/auth/jwt/oidc-providers/okta.mdx | 8 ++++--- .../auth/jwt/oidc-providers/secureauth.mdx | 8 ++++--- website/content/docs/auth/login-mfa/faq.mdx | 3 ++- website/content/docs/auth/login-mfa/index.mdx | 24 +++++++++++-------- website/content/docs/auth/saml/adfs.mdx | 3 +-- website/content/docs/auth/saml/index.mdx | 8 +++---- .../docs/auth/saml/link-vault-group-to-ad.mdx | 6 ++--- .../auth/saml/troubleshoot-adfs/index.mdx | 2 +- website/data/docs-nav-data.json | 24 +++++++++---------- 22 files changed, 101 insertions(+), 87 deletions(-) diff --git a/website/content/docs/auth/approle/approle-pattern.mdx b/website/content/docs/auth/approle/approle-pattern.mdx index 048d71c37709..47d6887d1979 100644 --- a/website/content/docs/auth/approle/approle-pattern.mdx +++ b/website/content/docs/auth/approle/approle-pattern.mdx @@ -1,11 +1,12 @@ --- layout: docs -page_title: AppRole recommended pattern and best practices +page_title: Best practices for AppRole authentication description: >- - The recommended pattern and best practices when you are using AppRole auth method to validate the identity of your application workloads. + Follow best practices for AppRole authentication to secure access and validate + application workload identity. --- -# AppRole recommended pattern and best practices +# Best practices for AppRole authentication At the core of Vault's usage is authentication and authorization. Understanding the methods that Vault surfaces these to the client is the key to understanding how to configure and manage Vault. diff --git a/website/content/docs/auth/approle/index.mdx b/website/content/docs/auth/approle/index.mdx index 8f0a6e6457e2..4c71a921126e 100644 --- a/website/content/docs/auth/approle/index.mdx +++ b/website/content/docs/auth/approle/index.mdx @@ -1,12 +1,12 @@ --- layout: docs -page_title: AppRole - Auth Methods -description: |- - The AppRole auth method allows machines and services to authenticate with - Vault. +page_title: Use AppRole authentication +description: >- + Use AppRole authentication with Vault to control how machines and services + authenticate to Vault. --- -# AppRole auth method +# Use AppRole authentication The `approle` auth method allows machines or _apps_ to authenticate with Vault-defined _roles_. The open design of `AppRole` enables a varied set of diff --git a/website/content/docs/auth/jwt/index.mdx b/website/content/docs/auth/jwt/index.mdx index 06961853e502..baae41c23c93 100644 --- a/website/content/docs/auth/jwt/index.mdx +++ b/website/content/docs/auth/jwt/index.mdx @@ -1,12 +1,11 @@ --- layout: docs -page_title: JWT/OIDC - Auth Methods +page_title: Use JWT/OIDC authentication description: >- - The JWT/OIDC auth method allows authentication using OIDC and user-provided - JWTs + Use JWT/OIDC authentication with Vault to support OIDC and user-provided JWTs. --- -# JWT/OIDC auth method +# Use JWT/OIDC authentication @include 'x509-sha1-deprecation.mdx' diff --git a/website/content/docs/auth/jwt/oidc-providers/adfs.mdx b/website/content/docs/auth/jwt/oidc-providers/adfs.mdx index 9578e99ab207..efdbd898f398 100644 --- a/website/content/docs/auth/jwt/oidc-providers/adfs.mdx +++ b/website/content/docs/auth/jwt/oidc-providers/adfs.mdx @@ -1,12 +1,12 @@ --- layout: docs -page_title: Configure Vault with ADFS for OIDC +page_title: Use with ADFS for OIDC description: >- Configure Vault to use Active Directory Federation Services (ADFS) as an OIDC provider. --- -# Configure Vault with ADFS for OIDC +# Use ADFS for OIDC authentication Configure your Vault instance to work with Active Directory Federation Services (ADFS) and use ADFS accounts with OIDC for Vault login. diff --git a/website/content/docs/auth/jwt/oidc-providers/auth0.mdx b/website/content/docs/auth/jwt/oidc-providers/auth0.mdx index 01499cada84f..c68ca2f881e7 100644 --- a/website/content/docs/auth/jwt/oidc-providers/auth0.mdx +++ b/website/content/docs/auth/jwt/oidc-providers/auth0.mdx @@ -1,10 +1,11 @@ --- layout: docs -page_title: OIDC Provider Setup - Auth Methods - Auth0 -description: OIDC provider configuration for Auth0 +page_title: Use Auth0 for OIDCauthentication +description: >- + Configure Vault to use Auth0 as an OIDC provider. --- -# Auth0 +# Use Auth0 for OIDC authentication 1. Select Create Application (Regular Web App). 1. Configure Allowed Callback URLs. diff --git a/website/content/docs/auth/jwt/oidc-providers/azuread.mdx b/website/content/docs/auth/jwt/oidc-providers/azuread.mdx index 717632d5b2d4..f28047a9dad9 100644 --- a/website/content/docs/auth/jwt/oidc-providers/azuread.mdx +++ b/website/content/docs/auth/jwt/oidc-providers/azuread.mdx @@ -1,10 +1,11 @@ --- layout: docs -page_title: OIDC Provider Setup - Auth Methods - Azure Active Directory -description: OIDC provider configuration for Azure Active Directory +page_title: Use Azure AD for OIDC +description: >- + Configure Vault to use Azure Active Directory (AD) as an OIDC provider. --- -# Azure active directory (AAD) +# Use Azure AD for OIDC authentication ~> **Note:** Azure Active Directory Applications that have custom signing keys as a result of using the [claims-mapping](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-claims-mapping) diff --git a/website/content/docs/auth/jwt/oidc-providers/forgerock.mdx b/website/content/docs/auth/jwt/oidc-providers/forgerock.mdx index 8f02b7652145..7122bd63203a 100644 --- a/website/content/docs/auth/jwt/oidc-providers/forgerock.mdx +++ b/website/content/docs/auth/jwt/oidc-providers/forgerock.mdx @@ -1,10 +1,11 @@ --- layout: docs -page_title: OIDC Provider Setup - Auth Methods - ForgeRock -description: OIDC provider configuration for ForgeRock +page_title: Use ForgeRock for OIDC +description: >- + Configure Vault to use ForgeRock as an OIDC provider. --- -# ForgeRock +# Use ForgeRock for OIDC authentication 1. Navigate to Applications -> OAuth 2.0 -> Clients in ForgeRock Access Management. 1. Create new client. diff --git a/website/content/docs/auth/jwt/oidc-providers/gitlab.mdx b/website/content/docs/auth/jwt/oidc-providers/gitlab.mdx index 8abf8ea78c87..e0acc1452c03 100644 --- a/website/content/docs/auth/jwt/oidc-providers/gitlab.mdx +++ b/website/content/docs/auth/jwt/oidc-providers/gitlab.mdx @@ -1,10 +1,11 @@ --- layout: docs -page_title: OIDC Provider Setup - Auth Methods - Gitlab -description: OIDC provider configuration for Gitlab +page_title: Use Gitlab for OIDC +description: >- + Configure Vault to use Gitlab as an OIDC provider. --- -# Gitlab +# Use Gitlab for OIDC authentication 1. Visit Settings > Applications. 1. Fill out Name and Redirect URIs. diff --git a/website/content/docs/auth/jwt/oidc-providers/google.mdx b/website/content/docs/auth/jwt/oidc-providers/google.mdx index 7ba7f73c7c63..1fe910d05a4d 100644 --- a/website/content/docs/auth/jwt/oidc-providers/google.mdx +++ b/website/content/docs/auth/jwt/oidc-providers/google.mdx @@ -1,10 +1,11 @@ --- layout: docs -page_title: OIDC Provider Setup - Auth Methods - Google -description: OIDC provider configuration for Google +page_title: Use Google for OIDC +description: >- + Configure Vault to use Google as an OIDC provider. --- -# Google +# Use Google for OIDC authentication Main reference: [Using OAuth 2.0 to Access Google APIs](https://developers.google.com/identity/protocols/OAuth2) diff --git a/website/content/docs/auth/jwt/oidc-providers/ibmisam.mdx b/website/content/docs/auth/jwt/oidc-providers/ibmisam.mdx index 1c304aba9062..94810e1dcab0 100644 --- a/website/content/docs/auth/jwt/oidc-providers/ibmisam.mdx +++ b/website/content/docs/auth/jwt/oidc-providers/ibmisam.mdx @@ -1,14 +1,16 @@ --- layout: docs -page_title: OIDC Provider Setup - Auth Methods - IBM Security Access Manager (ISAM) -description: OIDC provider configuration for IBM Security Access Manager (recently renamed to IBM Security Verify Access) +page_title: Use IBM Verify for OIDC authentication +description: >- + Configure Vault to use IBM Verify as an OIDC provider. --- -# IBM ISAM +# Use IBM Verify for OIDC authentication -The [IBM ISAM](https://www.ibm.com/de-de/products/verify-access) identity provider -returns group membership claims as a space-separated list of strings (e.g. -`groups: "group-1 group-2"`) instead of a list of strings. +The [IBM Verify](https://www.ibm.com/de-de/products/verify-access) identity +provider (previously IBM Security Access Manager) returns group membership +claims as a space-separated list of strings (e.g. `groups: "group-1 group-2"`) +instead of a list of strings. To properly obtain group membership when using IBMISAM as the identity provider for Vault's OIDC Auth Method, the `ibmisam` provider must be explicitly configured as diff --git a/website/content/docs/auth/jwt/oidc-providers/index.mdx b/website/content/docs/auth/jwt/oidc-providers/index.mdx index 3c2822047a4f..625969b4bd34 100644 --- a/website/content/docs/auth/jwt/oidc-providers/index.mdx +++ b/website/content/docs/auth/jwt/oidc-providers/index.mdx @@ -1,14 +1,11 @@ --- layout: docs -page_title: OIDC Provider Setup - Auth Methods -description: OIDC provider configuration quick starts +page_title: OIDC provider list +description: >- + Review available OIDC authentication providers for Vault. --- -# OIDC provider configuration - -This page collects high-level setup steps on how to configure an OIDC -application for various providers. For more general usage and operation -information, see the [Vault JWT/OIDC method documentation](/vault/docs/auth/jwt). +# OIDC provider list OIDC providers are often highly configurable, and you should become familiar with their recommended settings and best practices. The guides listed below are diff --git a/website/content/docs/auth/jwt/oidc-providers/keycloak.mdx b/website/content/docs/auth/jwt/oidc-providers/keycloak.mdx index 758e486cdccd..36cea892a884 100644 --- a/website/content/docs/auth/jwt/oidc-providers/keycloak.mdx +++ b/website/content/docs/auth/jwt/oidc-providers/keycloak.mdx @@ -1,10 +1,11 @@ --- layout: docs -page_title: OIDC Provider Setup - Auth Methods - Keycloak -description: OIDC provider configuration for Keycloak +page_title: Use Keycloak for OIDC authentication +description: >- + Configure Vault to use Keycloak as an OIDC provider. --- -# Keycloak +# Use Keycloak for OIDC authentication 1. Select/create a Realm and Client. Select a Client and visit Settings. 1. Client Protocol: openid-connect diff --git a/website/content/docs/auth/jwt/oidc-providers/kubernetes.mdx b/website/content/docs/auth/jwt/oidc-providers/kubernetes.mdx index 6f8d0379243d..b94f443a9401 100644 --- a/website/content/docs/auth/jwt/oidc-providers/kubernetes.mdx +++ b/website/content/docs/auth/jwt/oidc-providers/kubernetes.mdx @@ -1,10 +1,11 @@ --- layout: docs -page_title: OIDC Provider Setup - Auth Methods - Kubernetes -description: OIDC provider configuration for Kubernetes +page_title: Use Kubernetes for OIDC authentication +description: >- + Configure Vault to use Kubernetes as an OIDC provider. --- -# Kubernetes +# Use Kubernetes for OIDC authentication Kubernetes can function as an OIDC provider such that Vault can validate its service account tokens using JWT/OIDC auth. diff --git a/website/content/docs/auth/jwt/oidc-providers/okta.mdx b/website/content/docs/auth/jwt/oidc-providers/okta.mdx index d80f4942bee3..4c0f5ab18d7a 100644 --- a/website/content/docs/auth/jwt/oidc-providers/okta.mdx +++ b/website/content/docs/auth/jwt/oidc-providers/okta.mdx @@ -1,10 +1,12 @@ --- layout: docs -page_title: OIDC Provider Setup - Auth Methods - Okta -description: OIDC provider configuration for Okta +page_title: Use Okta for OIDC authentication +description: >- + Configure Vault to use Okta as an OIDC provider. --- -# Okta +# Use Okta for OIDC authentication + 1. Make sure an Authorization Server has been created. The "Issuer" field shown on the Setting page will be used as the `oidc_discovery_url`. diff --git a/website/content/docs/auth/jwt/oidc-providers/secureauth.mdx b/website/content/docs/auth/jwt/oidc-providers/secureauth.mdx index 491bc2d3c39e..2dc7959d7dc5 100644 --- a/website/content/docs/auth/jwt/oidc-providers/secureauth.mdx +++ b/website/content/docs/auth/jwt/oidc-providers/secureauth.mdx @@ -1,10 +1,12 @@ --- layout: docs -page_title: OIDC Provider Setup - Auth Methods - SecureAuth -description: OIDC provider configuration for SecureAuth +page_title: Use SecureAuth for OIDC authentication +description: >- + Configure Vault to use SecureAuth as an OIDC provider. --- -# SecureAuth +# Use SecureAuth for OIDC authentication + The [SecureAuth](https://www.secureauth.com/) identity provider returns group membership claims as a comma-separated list of strings (e.g. `groups: "group-1,group-2"`) instead diff --git a/website/content/docs/auth/login-mfa/faq.mdx b/website/content/docs/auth/login-mfa/faq.mdx index b50ab30e0b5c..7030ca48c8dd 100644 --- a/website/content/docs/auth/login-mfa/faq.mdx +++ b/website/content/docs/auth/login-mfa/faq.mdx @@ -1,7 +1,8 @@ --- layout: docs page_title: Login MFA FAQ -description: An FAQ page to answer the most commonly asked questions about login mfa. +description: >- + Commonly questions about Vault login MFA and multi-factor authentication. --- # Login MFA FAQ diff --git a/website/content/docs/auth/login-mfa/index.mdx b/website/content/docs/auth/login-mfa/index.mdx index f1dcd0f66bb0..139f375d2da7 100644 --- a/website/content/docs/auth/login-mfa/index.mdx +++ b/website/content/docs/auth/login-mfa/index.mdx @@ -1,19 +1,23 @@ --- layout: docs -page_title: Multi-Factor Authentication (MFA) for Login - Auth Methods -description: |- - Multi-factor authentication (MFA) is supported for several authentication - methods. +page_title: Set up login MFA +description: >- + Use basic multi-factor authentication (MFA) with Vault to add an extra level + of user verification to your authentication workflow for Vault. --- -# Login MFA +# Set up login MFA -Vault supports Multi-factor Authentication (MFA) for authenticating to -an auth method using different authentication types. We use the term `Login MFA` to distinguish -this feature and the [Vault Enterprise MFA](/vault/docs/enterprise/mfa). -Login MFA is built on top of the Identity system of Vault. +The underlying identity system in Vault supports multi-factor authentication +(MFA) for authenticating to an auth method using different authentication types. -## MFA types +MFA implementation | Required Vault edition +----------------------------------------- | ----------------------- +Login MFA | Vault Community +[Step-up MFA](/vault/docs/enterprise/mfa) | Vault Enterprise + + +## Login MFA types MFA in Vault includes the following login types: diff --git a/website/content/docs/auth/saml/adfs.mdx b/website/content/docs/auth/saml/adfs.mdx index 5d2172109257..bd3e606918f2 100644 --- a/website/content/docs/auth/saml/adfs.mdx +++ b/website/content/docs/auth/saml/adfs.mdx @@ -2,8 +2,7 @@ layout: docs page_title: Use Active Directory Federation Services for SAML description: >- - Configure Vault to use Active Directory Federation Services (AD FS) as a SAML - provider. + Use Active Directory Federation Services (AD FS) as a SAML provider for Vault. --- # Use Active Directory Federation Services for SAML diff --git a/website/content/docs/auth/saml/index.mdx b/website/content/docs/auth/saml/index.mdx index 7b004c1c6148..fa50e817c354 100644 --- a/website/content/docs/auth/saml/index.mdx +++ b/website/content/docs/auth/saml/index.mdx @@ -1,12 +1,12 @@ --- layout: docs -page_title: SAML - Auth Methods +page_title: Set up SAML authN description: >- - The "saml" auth method allows users to authenticate with Vault using their - identity in a SAML identity provider. + Use SAML authentication with Vault to authenticate Vault users with public + keys or certificates and a SAML identity provider. --- -# SAML auth method +# Set up SAML authentication @include 'alerts/enterprise-and-hcp.mdx' diff --git a/website/content/docs/auth/saml/link-vault-group-to-ad.mdx b/website/content/docs/auth/saml/link-vault-group-to-ad.mdx index 962d3dae740a..47c4e516b9d9 100644 --- a/website/content/docs/auth/saml/link-vault-group-to-ad.mdx +++ b/website/content/docs/auth/saml/link-vault-group-to-ad.mdx @@ -1,9 +1,9 @@ --- layout: docs -page_title: Link your SAML Active Directory groups to Vault +page_title: Link Active Directory SAML groups to Vault description: >- - Configure Vault to connect Vault policies to Active Directory groups with - Active Directory Federation Services (AD FS) as a SAML provider. + Connect Vault policies to Active Directory groups with Active Directory + Federation Services (AD FS) as a SAML provider. --- # Link Active Directory SAML groups to Vault diff --git a/website/content/docs/auth/saml/troubleshoot-adfs/index.mdx b/website/content/docs/auth/saml/troubleshoot-adfs/index.mdx index 7dbd90690dc5..02209cf82f2d 100644 --- a/website/content/docs/auth/saml/troubleshoot-adfs/index.mdx +++ b/website/content/docs/auth/saml/troubleshoot-adfs/index.mdx @@ -6,7 +6,7 @@ description: >- Services (ADFS) as an SAML provider. --- -# Troubleshoot your SAML AD FS configuration +# Troubleshoot AD FS: Before you start Troubleshooting guidance for solving problems with AD FS and SAML. diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index 2d173797820f..4d95e37961ad 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -1264,7 +1264,7 @@ ] }, { - "title": "Sinks", + "title": "Token sinks", "routes": [ { "title": "File", @@ -1872,7 +1872,7 @@ ] }, { - "title": "Auth Methods", + "title": "AuthN methods", "routes": [ { "title": "Overview", @@ -1882,11 +1882,11 @@ "title": "AppRole", "routes": [ { - "title": "Overview", + "title": "Use AppRole authN", "path": "auth/approle" }, { - "title": "Recommended pattern", + "title": "AppRole best practices", "path": "auth/approle/approle-pattern" } ] @@ -1919,14 +1919,14 @@ "title": "JWT/OIDC", "routes": [ { - "title": "Overview", + "title": "Use JWT/OIDC", "path": "auth/jwt" }, { - "title": "OIDC Providers", + "title": "OIDC providers", "routes": [ { - "title": "Overview", + "title": "OIDC provider list", "path": "auth/jwt/oidc-providers" }, { @@ -1970,7 +1970,7 @@ "path": "auth/jwt/oidc-providers/secureauth" }, { - "title": "IBM ISAM", + "title": "IBM Verify", "path": "auth/jwt/oidc-providers/ibmisam" } ] @@ -1993,11 +1993,11 @@ "title": "Login MFA", "routes": [ { - "title": "Overview", + "title": "Setup login MFA", "path": "auth/login-mfa" }, { - "title": "FAQ", + "title": "Login MFA FAQ", "path": "auth/login-mfa/faq" } ] @@ -2015,7 +2015,7 @@ "path": "auth/radius" }, { - "title": "SAML", + "title": "Use SAML authentication", "badge": { "text": "ENTERPRISE", "type": "outlined", @@ -2023,7 +2023,7 @@ }, "routes": [ { - "title": "Overview", + "title": "Set up SAML authN", "path": "auth/saml" }, { From 73bf3ebc7cfc01583021c22a461a4128d88801f8 Mon Sep 17 00:00:00 2001 From: miagilepner Date: Wed, 4 Dec 2024 11:09:10 +0100 Subject: [PATCH 16/45] VAULT-31755: Add removed and HA health to the sys/health endpoint (#28991) * logic * actually got test working * heartbeat health test * fix healthy definition and add changelog * fix test condition * actually fix test condition * Update vault/testing.go Co-authored-by: Kuba Wieczorek * close body --------- Co-authored-by: Kuba Wieczorek --- api/sys_health.go | 35 ++++--- changelog/28991.txt | 6 ++ http/sys_health.go | 66 +++++++++---- http/sys_health_test.go | 27 ++++++ vault/cluster/inmem_layer.go | 18 ++++ vault/core.go | 3 + vault/external_tests/raft/raft_test.go | 125 +++++++++++++++++++++++++ vault/ha.go | 23 +++++ vault/ha_test.go | 75 +++++++++++++++ vault/request_forwarding.go | 1 + vault/request_forwarding_rpc.go | 4 + vault/testing.go | 8 ++ 12 files changed, 360 insertions(+), 31 deletions(-) create mode 100644 changelog/28991.txt diff --git a/api/sys_health.go b/api/sys_health.go index 6868b96d77a0..4379e8e08a8e 100644 --- a/api/sys_health.go +++ b/api/sys_health.go @@ -25,6 +25,8 @@ func (c *Sys) HealthWithContext(ctx context.Context) (*HealthResponse, error) { r.Params.Add("standbycode", "299") r.Params.Add("drsecondarycode", "299") r.Params.Add("performancestandbycode", "299") + r.Params.Add("removedcode", "299") + r.Params.Add("haunhealthycode", "299") resp, err := c.c.rawRequestWithContext(ctx, r) if err != nil { @@ -38,19 +40,22 @@ func (c *Sys) HealthWithContext(ctx context.Context) (*HealthResponse, error) { } type HealthResponse struct { - Initialized bool `json:"initialized"` - Sealed bool `json:"sealed"` - Standby bool `json:"standby"` - PerformanceStandby bool `json:"performance_standby"` - ReplicationPerformanceMode string `json:"replication_performance_mode"` - ReplicationDRMode string `json:"replication_dr_mode"` - ServerTimeUTC int64 `json:"server_time_utc"` - Version string `json:"version"` - ClusterName string `json:"cluster_name,omitempty"` - ClusterID string `json:"cluster_id,omitempty"` - LastWAL uint64 `json:"last_wal,omitempty"` - Enterprise bool `json:"enterprise"` - EchoDurationMillis int64 `json:"echo_duration_ms"` - ClockSkewMillis int64 `json:"clock_skew_ms"` - ReplicationPrimaryCanaryAgeMillis int64 `json:"replication_primary_canary_age_ms"` + Initialized bool `json:"initialized"` + Sealed bool `json:"sealed"` + Standby bool `json:"standby"` + PerformanceStandby bool `json:"performance_standby"` + ReplicationPerformanceMode string `json:"replication_performance_mode"` + ReplicationDRMode string `json:"replication_dr_mode"` + ServerTimeUTC int64 `json:"server_time_utc"` + Version string `json:"version"` + ClusterName string `json:"cluster_name,omitempty"` + ClusterID string `json:"cluster_id,omitempty"` + LastWAL uint64 `json:"last_wal,omitempty"` + Enterprise bool `json:"enterprise"` + EchoDurationMillis int64 `json:"echo_duration_ms"` + ClockSkewMillis int64 `json:"clock_skew_ms"` + ReplicationPrimaryCanaryAgeMillis int64 `json:"replication_primary_canary_age_ms"` + RemovedFromCluster *bool `json:"removed_from_cluster,omitempty"` + HAConnectionHealthy *bool `json:"ha_connection_healthy,omitempty"` + LastRequestForwardingHeartbeatMillis int64 `json:"last_request_forwarding_heartbeat_ms,omitempty"` } diff --git a/changelog/28991.txt b/changelog/28991.txt new file mode 100644 index 000000000000..e7120258db2f --- /dev/null +++ b/changelog/28991.txt @@ -0,0 +1,6 @@ +```release-note:change +api: Add to sys/health whether the node has been removed from the HA cluster. If the node has been removed, return code 530 by default or the value of the `removedcode` query parameter. +``` +```release-note:change +api: Add to sys/health whether the standby node has been able to successfully send heartbeats to the active node and the time in milliseconds since the last heartbeat. If the standby has been unable to send a heartbeat, return code 474 by default or the value of the `haunhealthycode` query parameter. +``` diff --git a/http/sys_health.go b/http/sys_health.go index 0ed428d3d8ce..fece07602277 100644 --- a/http/sys_health.go +++ b/http/sys_health.go @@ -158,6 +158,20 @@ func getSysHealth(core *vault.Core, r *http.Request) (int, *HealthResponse, erro perfStandbyCode = code } + haUnhealthyCode := 474 + if code, found, ok := fetchStatusCode(r, "haunhealthycode"); !ok { + return http.StatusBadRequest, nil, nil + } else if found { + haUnhealthyCode = code + } + + removedCode := 530 + if code, found, ok := fetchStatusCode(r, "removedcode"); !ok { + return http.StatusBadRequest, nil, nil + } else if found { + removedCode = code + } + ctx := context.Background() // Check system status @@ -175,13 +189,21 @@ func getSysHealth(core *vault.Core, r *http.Request) (int, *HealthResponse, erro return http.StatusInternalServerError, nil, err } + removed, shouldIncludeRemoved := core.IsRemovedFromCluster() + + haHealthy, lastHeartbeat := core.GetHAHeartbeatHealth() + // Determine the status code code := activeCode switch { case !init: code = uninitCode + case removed: + code = removedCode case sealed: code = sealedCode + case !haHealthy && lastHeartbeat != nil: + code = haUnhealthyCode case replicationState.HasState(consts.ReplicationDRSecondary): code = drSecondaryCode case perfStandby: @@ -233,6 +255,15 @@ func getSysHealth(core *vault.Core, r *http.Request) (int, *HealthResponse, erro return http.StatusInternalServerError, nil, err } + if shouldIncludeRemoved { + body.RemovedFromCluster = &removed + } + + if lastHeartbeat != nil { + body.LastRequestForwardingHeartbeatMillis = lastHeartbeat.Milliseconds() + body.HAConnectionHealthy = &haHealthy + } + if licenseState != nil { body.License = &HealthResponseLicense{ State: licenseState.State, @@ -257,20 +288,23 @@ type HealthResponseLicense struct { } type HealthResponse struct { - Initialized bool `json:"initialized"` - Sealed bool `json:"sealed"` - Standby bool `json:"standby"` - PerformanceStandby bool `json:"performance_standby"` - ReplicationPerformanceMode string `json:"replication_performance_mode"` - ReplicationDRMode string `json:"replication_dr_mode"` - ServerTimeUTC int64 `json:"server_time_utc"` - Version string `json:"version"` - Enterprise bool `json:"enterprise"` - ClusterName string `json:"cluster_name,omitempty"` - ClusterID string `json:"cluster_id,omitempty"` - LastWAL uint64 `json:"last_wal,omitempty"` - License *HealthResponseLicense `json:"license,omitempty"` - EchoDurationMillis int64 `json:"echo_duration_ms"` - ClockSkewMillis int64 `json:"clock_skew_ms"` - ReplicationPrimaryCanaryAgeMillis int64 `json:"replication_primary_canary_age_ms"` + Initialized bool `json:"initialized"` + Sealed bool `json:"sealed"` + Standby bool `json:"standby"` + PerformanceStandby bool `json:"performance_standby"` + ReplicationPerformanceMode string `json:"replication_performance_mode"` + ReplicationDRMode string `json:"replication_dr_mode"` + ServerTimeUTC int64 `json:"server_time_utc"` + Version string `json:"version"` + Enterprise bool `json:"enterprise"` + ClusterName string `json:"cluster_name,omitempty"` + ClusterID string `json:"cluster_id,omitempty"` + LastWAL uint64 `json:"last_wal,omitempty"` + License *HealthResponseLicense `json:"license,omitempty"` + EchoDurationMillis int64 `json:"echo_duration_ms"` + ClockSkewMillis int64 `json:"clock_skew_ms"` + ReplicationPrimaryCanaryAgeMillis int64 `json:"replication_primary_canary_age_ms"` + RemovedFromCluster *bool `json:"removed_from_cluster,omitempty"` + HAConnectionHealthy *bool `json:"ha_connection_healthy,omitempty"` + LastRequestForwardingHeartbeatMillis int64 `json:"last_request_forwarding_heartbeat_ms,omitempty"` } diff --git a/http/sys_health_test.go b/http/sys_health_test.go index dcc3473b0636..d2480f4f5394 100644 --- a/http/sys_health_test.go +++ b/http/sys_health_test.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/vault/helper/constants" "github.com/hashicorp/vault/sdk/helper/consts" "github.com/hashicorp/vault/vault" + "github.com/stretchr/testify/require" ) func TestSysHealth_get(t *testing.T) { @@ -215,3 +216,29 @@ func TestSysHealth_head(t *testing.T) { } } } + +// TestSysHealth_Removed checks that a removed node returns a 530 and sets +// removed from cluster to be true. The test also checks that the removedcode +// query parameter is respected. +func TestSysHealth_Removed(t *testing.T) { + core, err := vault.TestCoreWithMockRemovableNodeHABackend(t, true) + require.NoError(t, err) + vault.TestCoreInit(t, core) + ln, addr := TestServer(t, core) + defer ln.Close() + raw, err := http.Get(addr + "/v1/sys/health") + require.NoError(t, err) + testResponseStatus(t, raw, 530) + healthResp := HealthResponse{} + testResponseBody(t, raw, &healthResp) + require.NotNil(t, healthResp.RemovedFromCluster) + require.True(t, *healthResp.RemovedFromCluster) + + raw, err = http.Get(addr + "/v1/sys/health?removedcode=299") + require.NoError(t, err) + testResponseStatus(t, raw, 299) + secondHealthResp := HealthResponse{} + testResponseBody(t, raw, &secondHealthResp) + require.NotNil(t, secondHealthResp.RemovedFromCluster) + require.True(t, *secondHealthResp.RemovedFromCluster) +} diff --git a/vault/cluster/inmem_layer.go b/vault/cluster/inmem_layer.go index aa28e153c2a2..3a2943d2be0d 100644 --- a/vault/cluster/inmem_layer.go +++ b/vault/cluster/inmem_layer.go @@ -116,6 +116,24 @@ func (l *InmemLayer) Listeners() []NetworkListener { return []NetworkListener{l.listener} } +// Partition forces the inmem layer to disconnect itself from peers and prevents +// creating new connections. The returned function will add all peers back +// and re-enable connections +func (l *InmemLayer) Partition() (unpartition func()) { + l.l.Lock() + peersCopy := make([]*InmemLayer, 0, len(l.peers)) + for _, peer := range l.peers { + peersCopy = append(peersCopy, peer) + } + l.l.Unlock() + l.DisconnectAll() + return func() { + for _, peer := range peersCopy { + l.Connect(peer) + } + } +} + // Dial implements NetworkLayer. func (l *InmemLayer) Dial(addr string, timeout time.Duration, tlsConfig *tls.Config) (*tls.Conn, error) { l.l.Lock() diff --git a/vault/core.go b/vault/core.go index 6c9c90087ee8..bad9a45e96eb 100644 --- a/vault/core.go +++ b/vault/core.go @@ -529,6 +529,8 @@ type Core struct { rpcClientConn *grpc.ClientConn // The grpc forwarding client rpcForwardingClient *forwardingClient + // The time of the last successful request forwarding heartbeat + rpcLastSuccessfulHeartbeat *atomic.Value // The UUID used to hold the leader lock. Only set on active node leaderUUID string @@ -1092,6 +1094,7 @@ func CreateCore(conf *CoreConfig) (*Core, error) { echoDuration: uberAtomic.NewDuration(0), activeNodeClockSkewMillis: uberAtomic.NewInt64(0), periodicLeaderRefreshInterval: conf.PeriodicLeaderRefreshInterval, + rpcLastSuccessfulHeartbeat: new(atomic.Value), } c.standbyStopCh.Store(make(chan struct{})) diff --git a/vault/external_tests/raft/raft_test.go b/vault/external_tests/raft/raft_test.go index 8c3b36ae6e99..dca1f4307698 100644 --- a/vault/external_tests/raft/raft_test.go +++ b/vault/external_tests/raft/raft_test.go @@ -31,8 +31,10 @@ import ( vaulthttp "github.com/hashicorp/vault/http" "github.com/hashicorp/vault/internalshared/configutil" "github.com/hashicorp/vault/physical/raft" + "github.com/hashicorp/vault/sdk/helper/jsonutil" "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/vault" + "github.com/hashicorp/vault/vault/cluster" vaultseal "github.com/hashicorp/vault/vault/seal" "github.com/stretchr/testify/require" "golang.org/x/net/http2" @@ -1414,3 +1416,126 @@ func TestRaftCluster_Removed_RaftConfig(t *testing.T) { }) require.Eventually(t, follower.Sealed, 10*time.Second, 500*time.Millisecond) } + +// TestSysHealth_Raft creates a raft cluster and verifies that the health status +// is OK for a healthy follower. The test partitions one of the nodes so that it +// can't send request forwarding RPCs. The test verifies that the status +// endpoint shows that HA isn't healthy. Finally, the test removes the +// partitioned follower and unpartitions it. The follower will learn that it has +// been removed, and should return the removed status. +func TestSysHealth_Raft(t *testing.T) { + parseHealthBody := func(t *testing.T, resp *api.Response) *vaulthttp.HealthResponse { + t.Helper() + health := vaulthttp.HealthResponse{} + defer resp.Body.Close() + require.NoError(t, jsonutil.DecodeJSONFromReader(resp.Body, &health)) + return &health + } + + opts := &vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + NumCores: 3, + InmemClusterLayers: true, + } + heartbeat := 500 * time.Millisecond + teststorage.RaftBackendSetup(nil, opts) + conf := &vault.CoreConfig{ + ClusterHeartbeatInterval: heartbeat, + } + vaultCluster := vault.NewTestCluster(t, conf, opts) + defer vaultCluster.Cleanup() + testhelpers.WaitForActiveNodeAndStandbys(t, vaultCluster) + followerClient := vaultCluster.Cores[1].Client + + t.Run("healthy", func(t *testing.T) { + resp, err := followerClient.Logical().ReadRawWithData("sys/health", map[string][]string{ + "perfstandbyok": {"true"}, + "standbyok": {"true"}, + }) + require.NoError(t, err) + require.Equal(t, resp.StatusCode, 200) + r := parseHealthBody(t, resp) + require.False(t, *r.RemovedFromCluster) + require.True(t, *r.HAConnectionHealthy) + require.Less(t, r.LastRequestForwardingHeartbeatMillis, 2*heartbeat.Milliseconds()) + }) + nl := vaultCluster.Cores[1].NetworkLayer() + inmem, ok := nl.(*cluster.InmemLayer) + require.True(t, ok) + unpartition := inmem.Partition() + + t.Run("partition", func(t *testing.T) { + time.Sleep(2 * heartbeat) + var erroredResponse *api.Response + // the node isn't able to send/receive heartbeats, so it will have + // haunhealthy status. + testhelpers.RetryUntil(t, 3*time.Second, func() error { + resp, err := followerClient.Logical().ReadRawWithData("sys/health", map[string][]string{ + "perfstandbyok": {"true"}, + "standbyok": {"true"}, + }) + if err == nil { + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + return errors.New("expected error") + } + if resp.StatusCode != 474 { + resp.Body.Close() + return fmt.Errorf("status code %d", resp.StatusCode) + } + erroredResponse = resp + return nil + }) + r := parseHealthBody(t, erroredResponse) + require.False(t, *r.RemovedFromCluster) + require.False(t, *r.HAConnectionHealthy) + require.Greater(t, r.LastRequestForwardingHeartbeatMillis, 2*heartbeat.Milliseconds()) + + // ensure haunhealthycode is respected + resp, err := followerClient.Logical().ReadRawWithData("sys/health", map[string][]string{ + "perfstandbyok": {"true"}, + "standbyok": {"true"}, + "haunhealthycode": {"299"}, + }) + require.NoError(t, err) + require.Equal(t, 299, resp.StatusCode) + resp.Body.Close() + }) + + t.Run("remove and unpartition", func(t *testing.T) { + leaderClient := vaultCluster.Cores[0].Client + _, err := leaderClient.Logical().Write("sys/storage/raft/remove-peer", map[string]interface{}{ + "server_id": vaultCluster.Cores[1].NodeID, + }) + require.NoError(t, err) + unpartition() + + var erroredResponse *api.Response + + // now that the node can connect again, it will start getting the removed + // error when trying to connect. The code should be removed, and the ha + // connection will be nil because there is no ha connection + testhelpers.RetryUntil(t, 10*time.Second, func() error { + resp, err := followerClient.Logical().ReadRawWithData("sys/health", map[string][]string{ + "perfstandbyok": {"true"}, + "standbyok": {"true"}, + }) + if err == nil { + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + return fmt.Errorf("expected error") + } + if resp.StatusCode != 530 { + resp.Body.Close() + return fmt.Errorf("status code %d", resp.StatusCode) + } + erroredResponse = resp + return nil + }) + r := parseHealthBody(t, erroredResponse) + require.True(t, true, *r.RemovedFromCluster) + require.Nil(t, r.HAConnectionHealthy) + }) +} diff --git a/vault/ha.go b/vault/ha.go index 46fc7f7757b4..2368e24f8b11 100644 --- a/vault/ha.go +++ b/vault/ha.go @@ -46,6 +46,8 @@ const ( // leaderPrefixCleanDelay is how long to wait between deletions // of orphaned leader keys, to prevent slamming the backend. leaderPrefixCleanDelay = 200 * time.Millisecond + + haAllowedMissedHeartbeats = 2 ) func init() { @@ -1236,3 +1238,24 @@ func (c *Core) getRemovableHABackend() physical.RemovableNodeHABackend { return haBackend } + +// GetHAHeartbeatHealth returns whether a node's last successful heartbeat was +// more than 2 intervals ago. If the node's request forwarding clients were +// cleared (due to the node being sealed or finding a new leader), or the node +// is uninitialized, healthy will be false. +func (c *Core) GetHAHeartbeatHealth() (healthy bool, sinceLastHeartbeat *time.Duration) { + heartbeat := c.rpcLastSuccessfulHeartbeat.Load() + if heartbeat == nil { + return false, nil + } + lastHeartbeat := heartbeat.(time.Time) + if lastHeartbeat.IsZero() { + return false, nil + } + diff := time.Now().Sub(lastHeartbeat) + heartbeatInterval := c.clusterHeartbeatInterval + if heartbeatInterval <= 0 { + heartbeatInterval = 5 * time.Second + } + return diff < heartbeatInterval*haAllowedMissedHeartbeats, &diff +} diff --git a/vault/ha_test.go b/vault/ha_test.go index 77444b99a9d2..b305749e0e79 100644 --- a/vault/ha_test.go +++ b/vault/ha_test.go @@ -10,6 +10,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/stretchr/testify/require" ) // TestGrabLockOrStop is a non-deterministic test to detect deadlocks in the @@ -85,3 +87,76 @@ func TestGrabLockOrStop(t *testing.T) { } workerWg.Wait() } + +// TestGetHAHeartbeatHealth checks that heartbeat health is correctly determined +// for a variety of scenarios +func TestGetHAHeartbeatHealth(t *testing.T) { + now := time.Now().UTC() + oldLastHeartbeat := now.Add(-1 * time.Hour) + futureHeartbeat := now.Add(10 * time.Second) + zeroHeartbeat := time.Time{} + testCases := []struct { + name string + lastHeartbeat *time.Time + heartbeatInterval time.Duration + wantHealthy bool + }{ + { + name: "old heartbeat", + lastHeartbeat: &oldLastHeartbeat, + heartbeatInterval: 5 * time.Second, + wantHealthy: false, + }, + { + name: "no heartbeat", + lastHeartbeat: nil, + heartbeatInterval: 5 * time.Second, + wantHealthy: false, + }, + { + name: "recent heartbeat", + lastHeartbeat: &now, + heartbeatInterval: 20 * time.Second, + wantHealthy: true, + }, + { + name: "recent heartbeat, empty interval", + lastHeartbeat: &futureHeartbeat, + heartbeatInterval: 0, + wantHealthy: true, + }, + { + name: "old heartbeat, empty interval", + lastHeartbeat: &oldLastHeartbeat, + heartbeatInterval: 0, + wantHealthy: false, + }, + { + name: "zero value heartbeat", + lastHeartbeat: &zeroHeartbeat, + heartbeatInterval: 5 * time.Second, + wantHealthy: false, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + v := new(atomic.Value) + if tc.lastHeartbeat != nil { + v.Store(*tc.lastHeartbeat) + } + c := &Core{ + rpcLastSuccessfulHeartbeat: v, + clusterHeartbeatInterval: tc.heartbeatInterval, + } + + now := time.Now() + gotHealthy, gotLastHeartbeat := c.GetHAHeartbeatHealth() + require.Equal(t, tc.wantHealthy, gotHealthy) + if tc.lastHeartbeat != nil && !tc.lastHeartbeat.IsZero() { + require.InDelta(t, now.Sub(*tc.lastHeartbeat).Milliseconds(), gotLastHeartbeat.Milliseconds(), float64(3*time.Second.Milliseconds())) + } else { + require.Nil(t, gotLastHeartbeat) + } + }) + } +} diff --git a/vault/request_forwarding.go b/vault/request_forwarding.go index a0857c061376..7ad117a6cc34 100644 --- a/vault/request_forwarding.go +++ b/vault/request_forwarding.go @@ -442,6 +442,7 @@ func (c *Core) clearForwardingClients() { clusterListener.RemoveClient(consts.RequestForwardingALPN) } c.clusterLeaderParams.Store((*ClusterLeaderParams)(nil)) + c.rpcLastSuccessfulHeartbeat.Store(time.Time{}) } // ForwardRequest forwards a given request to the active node and returns the diff --git a/vault/request_forwarding_rpc.go b/vault/request_forwarding_rpc.go index bcca7d06dab9..ad4c0fb809e9 100644 --- a/vault/request_forwarding_rpc.go +++ b/vault/request_forwarding_rpc.go @@ -201,6 +201,7 @@ func (c *forwardingClient) startHeartbeat() { c.core.logger.Debug("forwarding: error sending echo request to active node", "error", err) return } + c.core.rpcLastSuccessfulHeartbeat.Store(now) if resp == nil { c.core.logger.Debug("forwarding: empty echo response from active node") return @@ -214,6 +215,9 @@ func (c *forwardingClient) startHeartbeat() { atomic.StoreUint32(c.core.activeNodeReplicationState, resp.ReplicationState) } + // store a value before the first tick to indicate that we've started + // sending heartbeats + c.core.rpcLastSuccessfulHeartbeat.Store(time.Now()) tick() for { diff --git a/vault/testing.go b/vault/testing.go index 41bf429acd4c..bd5c6d0fcae0 100644 --- a/vault/testing.go +++ b/vault/testing.go @@ -977,6 +977,14 @@ func (c *TestClusterCore) ClusterListener() *cluster.Listener { return c.getClusterListener() } +// NetworkLayer returns the network layer for the cluster core. This can be used +// in conjunction with the cluster.InmemLayer to disconnect specific nodes from +// the cluster when we need to simulate abrupt node failure or a network +// partition in NewTestCluster tests. +func (c *TestClusterCore) NetworkLayer() cluster.NetworkLayer { + return c.Core.clusterNetworkLayer +} + func (c *TestCluster) Cleanup() { c.Logger.Info("cleaning up vault cluster") if tl, ok := c.Logger.(*corehelpers.TestLogger); ok { From a67e062254fbfea73baae649c626a7fe6f32b289 Mon Sep 17 00:00:00 2001 From: Mike Palmiotto Date: Wed, 4 Dec 2024 13:23:55 -0500 Subject: [PATCH 17/45] Fix unlocked mounts read (#29091) This PR fixes copy-paste error in the product usage code where we were taking out the authLock to access the mount table. While we're add it we can remove the existing lock grabbing in the product usage goroutine in favor of a serialized startup/teardown of censusManager and its core dependency which requires the lock. This requires some minor test edits, so created a test helper for that. By moving the censusManager teardown before expirationManager teardown, we can effectively ensure the goroutine is completely stopped outside of any expirationManager change. We are already guaranteed serial startup, so this should free us of any complex lock semantics. --- changelog/29091.txt | 3 +++ vault/core.go | 11 +++++++---- vault/core_metrics.go | 26 ++++++-------------------- vault/expiration.go | 7 +++++-- vault/expiration_test.go | 32 ++++++++++++++++++++------------ vault/token_store_test.go | 5 +---- 6 files changed, 42 insertions(+), 42 deletions(-) create mode 100644 changelog/29091.txt diff --git a/changelog/29091.txt b/changelog/29091.txt new file mode 100644 index 000000000000..409d8ddf4a67 --- /dev/null +++ b/changelog/29091.txt @@ -0,0 +1,3 @@ +```release-note:bug +core/metrics: Fix unlocked mounts read for usage reporting. +``` diff --git a/vault/core.go b/vault/core.go index bad9a45e96eb..d9e7ad62de50 100644 --- a/vault/core.go +++ b/vault/core.go @@ -2898,14 +2898,17 @@ func (c *Core) preSeal() error { if err := c.teardownAudits(); err != nil { result = multierror.Append(result, fmt.Errorf("error tearing down audits: %w", err)) } - if err := c.stopExpiration(); err != nil { - result = multierror.Append(result, fmt.Errorf("error stopping expiration: %w", err)) - } + // Ensure that the ActivityLog and CensusManager are both completely torn + // down before stopping the ExpirationManager. This ordering is critical, + // due to a tight coupling between the ActivityLog, CensusManager, and + // ExpirationManager for product usage reporting. c.stopActivityLog() - // Clean up census on seal if err := c.teardownCensusManager(); err != nil { result = multierror.Append(result, fmt.Errorf("error tearing down reporting agent: %w", err)) } + if err := c.stopExpiration(); err != nil { + result = multierror.Append(result, fmt.Errorf("error stopping expiration: %w", err)) + } if err := c.teardownCredentials(context.Background()); err != nil { result = multierror.Append(result, fmt.Errorf("error tearing down credentials: %w", err)) } diff --git a/vault/core_metrics.go b/vault/core_metrics.go index 0ca9a0e09432..60f0295d144f 100644 --- a/vault/core_metrics.go +++ b/vault/core_metrics.go @@ -540,22 +540,15 @@ func getMeanNamespaceSecrets(mapOfNamespacesToSecrets map[string]int) int { func (c *Core) GetSecretEngineUsageMetrics() map[string]int { mounts := make(map[string]int) - c.authLock.RLock() - defer c.authLock.RUnlock() - - // we don't grab the statelock, so this code might run during or after the seal process. - // Therefore, we need to check if c.auth is nil. If we do not, this will panic when - // run after seal. - if c.auth == nil { - return mounts - } + c.mountsLock.RLock() + defer c.mountsLock.RUnlock() for _, entry := range c.mounts.Entries { - authType := entry.Type - if _, ok := mounts[authType]; !ok { - mounts[authType] = 1 + mountType := entry.Type + if _, ok := mounts[mountType]; !ok { + mounts[mountType] = 1 } else { - mounts[authType] += 1 + mounts[mountType] += 1 } } return mounts @@ -568,13 +561,6 @@ func (c *Core) GetAuthMethodUsageMetrics() map[string]int { c.authLock.RLock() defer c.authLock.RUnlock() - // we don't grab the statelock, so this code might run during or after the seal process. - // Therefore, we need to check if c.auth is nil. If we do not, this will panic when - // run after seal. - if c.auth == nil { - return mounts - } - for _, entry := range c.auth.Entries { authType := entry.Type if _, ok := mounts[authType]; !ok { diff --git a/vault/expiration.go b/vault/expiration.go index c2f652020ce2..37cb26c559f3 100644 --- a/vault/expiration.go +++ b/vault/expiration.go @@ -435,8 +435,11 @@ func (c *Core) setupExpiration(e ExpireLeaseStrategy) error { return nil } -// stopExpiration is used to stop the expiration manager before -// sealing the Vault. +// stopExpiration is used to stop the expiration manager before sealing Vault. +// This *must* be called after shutting down the ActivityLog and +// CensusManager to prevent Core's expirationManager reference from +// changing while being accessed by product usage reporting. This is +// an unfortunate side-effect of tight coupling between ActivityLog and Core. func (c *Core) stopExpiration() error { if c.expiration != nil { if err := c.expiration.Stop(); err != nil { diff --git a/vault/expiration_test.go b/vault/expiration_test.go index 9e0da07bdb5e..2ef631562596 100644 --- a/vault/expiration_test.go +++ b/vault/expiration_test.go @@ -855,10 +855,7 @@ func TestExpiration_Restore(t *testing.T) { } // Stop everything - err = c.stopExpiration() - if err != nil { - t.Fatalf("err: %v", err) - } + stopExpiration(t, c) if exp.leaseCount != 0 { t.Fatalf("expected %v leases, got %v", 0, exp.leaseCount) @@ -3008,6 +3005,23 @@ func registerOneLease(t *testing.T, ctx context.Context, exp *ExpirationManager) return leaseID } +// stopExpiration is a test helper which allows us to safely teardown the +// expiration manager. This preserves the shutdown order of Core for these few +// outlier tests that (previously) directly called [Core].stopExpiration(). +func stopExpiration(t *testing.T, core *Core) { + t.Helper() + core.stopActivityLog() + err := core.teardownCensusManager() + if err != nil { + t.Fatalf("error stopping census manager: %v", err) + } + + err = core.stopExpiration() + if err != nil { + t.Fatalf("error stopping expiration manager: %v", err) + } +} + func TestExpiration_MarkIrrevocable(t *testing.T) { c, _, _ := TestCoreUnsealed(t) exp := c.expiration @@ -3060,10 +3074,7 @@ func TestExpiration_MarkIrrevocable(t *testing.T) { } // stop and restore to verify that irrevocable leases are properly loaded from storage - err = c.stopExpiration() - if err != nil { - t.Fatalf("error stopping expiration manager: %v", err) - } + stopExpiration(t, c) err = exp.Restore(nil) if err != nil { @@ -3153,10 +3164,7 @@ func TestExpiration_StopClearsIrrevocableCache(t *testing.T) { exp.markLeaseIrrevocable(ctx, le, fmt.Errorf("test irrevocable error")) exp.pendingLock.Unlock() - err = c.stopExpiration() - if err != nil { - t.Fatalf("error stopping expiration manager: %v", err) - } + stopExpiration(t, c) if _, ok := exp.irrevocable.Load(leaseID); ok { t.Error("expiration manager irrevocable cache should be cleared on stop") diff --git a/vault/token_store_test.go b/vault/token_store_test.go index 8223108e9876..6d58616966f9 100644 --- a/vault/token_store_test.go +++ b/vault/token_store_test.go @@ -1170,10 +1170,7 @@ func TestTokenStore_CreateLookup_ExpirationInRestoreMode(t *testing.T) { t.Fatalf("err: %v", err) } - err = c.stopExpiration() - if err != nil { - t.Fatal(err) - } + stopExpiration(t, c) // Reset expiration manager to restore mode ts.expiration.restoreModeLock.Lock() From bc09d9acecb585a1c5b4b31147f0825ebe29f8b1 Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Wed, 4 Dec 2024 12:14:08 -0800 Subject: [PATCH 18/45] Docs: Add updated screenshots to kv subkey docs (#29067) * clarify subkey read in GUI * add screenshots * add to index * update kv nav steps * update alt text for screenshot * update steps * edits * fix build error and simplify path structure * fix paths * missed one * missed another one >_< * Update website/content/docs/secrets/kv/kv-v2/cookbook/write-data.mdx --------- Co-authored-by: Sarah Chavis <62406755+schavis@users.noreply.github.com> --- website/content/docs/auth/saml/adfs.mdx | 2 +- .../docs/auth/saml/link-vault-group-to-ad.mdx | 6 +++--- .../kv/kv-v2/cookbook/custom-metadata.mdx | 3 +-- .../secrets/kv/kv-v2/cookbook/delete-data.mdx | 4 ++-- .../secrets/kv/kv-v2/cookbook/destroy-data.mdx | 4 ++-- .../secrets/kv/kv-v2/cookbook/max-versions.mdx | 3 +-- .../secrets/kv/kv-v2/cookbook/patch-data.mdx | 5 +++-- .../secrets/kv/kv-v2/cookbook/read-data.mdx | 4 ++-- .../secrets/kv/kv-v2/cookbook/read-subkey.mdx | 14 ++++++++------ .../kv/kv-v2/cookbook/undelete-data.mdx | 4 ++-- .../secrets/kv/kv-v2/cookbook/write-data.mdx | 7 ++++--- .../content/docs/secrets/kv/kv-v2/index.mdx | 1 + .../docs/secrets/kv/kv-v2/random-string.mdx | 5 +++-- .../content/docs/secrets/kv/kv-v2/setup.mdx | 4 ++-- .../create-acl-policy.mdx | 0 .../create-group.mdx | 0 .../enable-authn-plugin.mdx | 0 .../enable-secrets-plugin.mdx | 0 .../plugins/kv/open-overview.mdx} | 6 ++---- .../gui-page-instructions/select-kv-mount.mdx | 10 ---------- website/public/img/gui/kv/overview-page.png | Bin 0 -> 212503 bytes .../public/img/gui/kv/patch-reveal-subkeys.png | Bin 0 -> 179959 bytes 22 files changed, 37 insertions(+), 45 deletions(-) rename website/content/partials/{gui-page-instructions => gui-instructions}/create-acl-policy.mdx (100%) rename website/content/partials/{gui-page-instructions => gui-instructions}/create-group.mdx (100%) rename website/content/partials/{gui-page-instructions => gui-instructions}/enable-authn-plugin.mdx (100%) rename website/content/partials/{gui-page-instructions => gui-instructions}/enable-secrets-plugin.mdx (100%) rename website/content/partials/{gui-page-instructions/select-kv-data.mdx => gui-instructions/plugins/kv/open-overview.mdx} (81%) delete mode 100644 website/content/partials/gui-page-instructions/select-kv-mount.mdx create mode 100644 website/public/img/gui/kv/overview-page.png create mode 100644 website/public/img/gui/kv/patch-reveal-subkeys.png diff --git a/website/content/docs/auth/saml/adfs.mdx b/website/content/docs/auth/saml/adfs.mdx index bd3e606918f2..137ac2a1785a 100644 --- a/website/content/docs/auth/saml/adfs.mdx +++ b/website/content/docs/auth/saml/adfs.mdx @@ -55,7 +55,7 @@ Configure your Vault instance to work with Active Directory Federation Services -@include 'gui-page-instructions/enable-authn-plugin.mdx' +@include 'gui-instructions/enable-authn-plugin.mdx' - Enable the SAML plugin: diff --git a/website/content/docs/auth/saml/link-vault-group-to-ad.mdx b/website/content/docs/auth/saml/link-vault-group-to-ad.mdx index 47c4e516b9d9..0e9e5274b8f7 100644 --- a/website/content/docs/auth/saml/link-vault-group-to-ad.mdx +++ b/website/content/docs/auth/saml/link-vault-group-to-ad.mdx @@ -52,7 +52,7 @@ $ vault secrets enable -path=adfs-kv kv-v2 -@include 'gui-page-instructions/enable-secrets-plugin.mdx' +@include 'gui-instructions/enable-secrets-plugin.mdx' - Enable the KV plugin: @@ -102,7 +102,7 @@ EOF -@include 'gui-page-instructions/create-acl-policy.mdx' +@include 'gui-instructions/create-acl-policy.mdx' - Set the policy details and click **Create policy**: @@ -161,7 +161,7 @@ EOF -@include 'gui-page-instructions/create-group.mdx' +@include 'gui-instructions/create-group.mdx' - Follow the prompts to create an external group with the following information: diff --git a/website/content/docs/secrets/kv/kv-v2/cookbook/custom-metadata.mdx b/website/content/docs/secrets/kv/kv-v2/cookbook/custom-metadata.mdx index 849a6277e583..0aae6bca0ee0 100644 --- a/website/content/docs/secrets/kv/kv-v2/cookbook/custom-metadata.mdx +++ b/website/content/docs/secrets/kv/kv-v2/cookbook/custom-metadata.mdx @@ -85,9 +85,8 @@ destroyed false -@include 'gui-page-instructions/select-kv-mount.mdx' +@include 'gui-instructions/plugins/kv/open-overview.mdx' -- Click through the path segments to select the relevant secret path. - Select the **Metadata** tab. - Click **Edit metadata >**. - Set a new key name and value under **Custom metadata**. diff --git a/website/content/docs/secrets/kv/kv-v2/cookbook/delete-data.mdx b/website/content/docs/secrets/kv/kv-v2/cookbook/delete-data.mdx index d2819d304b0d..2a2907e7e745 100644 --- a/website/content/docs/secrets/kv/kv-v2/cookbook/delete-data.mdx +++ b/website/content/docs/secrets/kv/kv-v2/cookbook/delete-data.mdx @@ -95,9 +95,9 @@ destroyed false -@include 'gui-page-instructions/select-kv-mount.mdx' +@include 'gui-instructions/plugins/kv/open-overview.mdx' -- Click through the path segments to select the relevant secret path. +- Select the **Secret** tab. - Select the appropriate data version from the **Version** dropdown. - Click **Delete**. - Select **Delete this version** to delete the selected version or diff --git a/website/content/docs/secrets/kv/kv-v2/cookbook/destroy-data.mdx b/website/content/docs/secrets/kv/kv-v2/cookbook/destroy-data.mdx index 8bcd2b4052f1..c0dc4be39b98 100644 --- a/website/content/docs/secrets/kv/kv-v2/cookbook/destroy-data.mdx +++ b/website/content/docs/secrets/kv/kv-v2/cookbook/destroy-data.mdx @@ -92,9 +92,9 @@ destroyed true -@include 'gui-page-instructions/select-kv-mount.mdx' +@include 'gui-instructions/plugins/kv/open-overview.mdx' -- Click through the path segments to select the relevant secret path. +- Select the **Secret** tab. - Select the appropriate data version from the **Version** dropdown. - Click **Destroy**. - Click **Confirm**. diff --git a/website/content/docs/secrets/kv/kv-v2/cookbook/max-versions.mdx b/website/content/docs/secrets/kv/kv-v2/cookbook/max-versions.mdx index f7763f94db4a..81c50158a50c 100644 --- a/website/content/docs/secrets/kv/kv-v2/cookbook/max-versions.mdx +++ b/website/content/docs/secrets/kv/kv-v2/cookbook/max-versions.mdx @@ -84,9 +84,8 @@ destroyed false -@include 'gui-page-instructions/select-kv-mount.mdx' +@include 'gui-instructions/plugins/kv/open-overview.mdx' -- Click through the path segments to select the relevant secret path. - Select the **Metadata** tab. - Click **Edit metadata >**. - Update the **Maximum number of versions** field. diff --git a/website/content/docs/secrets/kv/kv-v2/cookbook/patch-data.mdx b/website/content/docs/secrets/kv/kv-v2/cookbook/patch-data.mdx index fdceefc632eb..bf3e1ce3d4cb 100644 --- a/website/content/docs/secrets/kv/kv-v2/cookbook/patch-data.mdx +++ b/website/content/docs/secrets/kv/kv-v2/cookbook/patch-data.mdx @@ -114,9 +114,10 @@ push the update to the plugin. @include 'alerts/enterprise-only.mdx' -@include 'gui-page-instructions/select-kv-data.mdx' +@include 'gui-instructions/plugins/kv/open-overview.mdx' -- Click **Patch latest version +** on the key/value page. +- Select the **Secret** tab. +- Click **Patch latest version +**. - Edit the values you want to update. - Click **Save**. diff --git a/website/content/docs/secrets/kv/kv-v2/cookbook/read-data.mdx b/website/content/docs/secrets/kv/kv-v2/cookbook/read-data.mdx index 19763bd7f077..161cecb12685 100644 --- a/website/content/docs/secrets/kv/kv-v2/cookbook/read-data.mdx +++ b/website/content/docs/secrets/kv/kv-v2/cookbook/read-data.mdx @@ -82,9 +82,9 @@ $ vault kv get -mount shared -field prod dev/square-api -@include 'gui-page-instructions/select-kv-mount.mdx' +@include 'gui-instructions/plugins/kv/open-overview.mdx' -- Click through the path segments to select the relevant secret path. +- Select the **Secret** tab. - Click the eye icon to view the desired key value. ![Partial screenshot of the Vault GUI showing two key/value pairs at the path dev/square-api. The "prod" key is visible](/img/gui/kv/read-data.png) diff --git a/website/content/docs/secrets/kv/kv-v2/cookbook/read-subkey.mdx b/website/content/docs/secrets/kv/kv-v2/cookbook/read-subkey.mdx index 9820bf7702be..3708c48a5cf1 100644 --- a/website/content/docs/secrets/kv/kv-v2/cookbook/read-subkey.mdx +++ b/website/content/docs/secrets/kv/kv-v2/cookbook/read-subkey.mdx @@ -21,8 +21,8 @@ Read the available subkeys on an existing data path in the `kv` v2 plugin. -Use `vault read` with the `/subkeys` metadata path retrieve a list of available -subkeys on the given path. +Use `vault read` with the `/subkeys` path to retrieve a list of secret data +subkeys at the given path. ```shell-session $ vault read /subkeys/ @@ -50,12 +50,14 @@ subkeys map[prod: sandbox: smoke:] -@include 'gui-page-instructions/select-kv-mount.mdx' +@include 'alerts/enterprise-only.mdx' -- Click through the path segments to select the relevant secret path. -- Note the subkeys listed on the data page. +@include 'gui-instructions/plugins/kv/open-overview.mdx' -![Partial screenshot of the Vault GUI showing two key/value pairs at the path dev/square-api. The "prod" key is visible](/img/gui/kv/read-data.png) +You can read a list of available subkeys for the target path in the **Subkeys** +card. + +![Partial screenshot of the Vault GUI showing subkeys "prod" and "sandbox" for secret data at path dev/square-api.](/img/gui/kv/overview-page.png) diff --git a/website/content/docs/secrets/kv/kv-v2/cookbook/undelete-data.mdx b/website/content/docs/secrets/kv/kv-v2/cookbook/undelete-data.mdx index 176eea4deb4a..c8954e7899b0 100644 --- a/website/content/docs/secrets/kv/kv-v2/cookbook/undelete-data.mdx +++ b/website/content/docs/secrets/kv/kv-v2/cookbook/undelete-data.mdx @@ -90,9 +90,9 @@ destroyed false -@include 'gui-page-instructions/select-kv-mount.mdx' +@include 'gui-instructions/plugins/kv/open-overview.mdx' -- Click through the path segments to select the relevant secret path. +- Select the **Secret** tab. - Select the appropriate data version from the **Version** dropdown. - Click **Undelete**. diff --git a/website/content/docs/secrets/kv/kv-v2/cookbook/write-data.mdx b/website/content/docs/secrets/kv/kv-v2/cookbook/write-data.mdx index 1250f25f171d..b84c2392abf5 100644 --- a/website/content/docs/secrets/kv/kv-v2/cookbook/write-data.mdx +++ b/website/content/docs/secrets/kv/kv-v2/cookbook/write-data.mdx @@ -72,10 +72,11 @@ The Vault GUI forcibly converts non-string keys to strings before writing data. To preserve non-string values, use the JSON toggle to write your key/value data as JSON. -@include 'gui-page-instructions/select-kv-mount.mdx' +@include 'gui-instructions/plugins/kv/open-overview.mdx' -- Click through the path segments to select the relevant secret path. -- Click **Create new version +**. +- Click **Create new +** from one of the following tabs: + - **Overview** tab: in the "Current version" card. + - **Secret** tab: in the toolbar. - Set a new key name and value. - Use the **Add** button to set additional key/value pairs. - Click **Save** to write the new version data. diff --git a/website/content/docs/secrets/kv/kv-v2/index.mdx b/website/content/docs/secrets/kv/kv-v2/index.mdx index 19c292478cb5..974ce55f4249 100644 --- a/website/content/docs/secrets/kv/kv-v2/index.mdx +++ b/website/content/docs/secrets/kv/kv-v2/index.mdx @@ -35,6 +35,7 @@ Basic examples: - [Set max data versions](/vault/docs/secrets/kv/kv-v2/cookbook/max-versions) - [Write data](/vault/docs/secrets/kv/kv-v2/cookbook/write-data) - [Patch and update data](/vault/docs/secrets/kv/kv-v2/cookbook/patch-data) +- [Read subkeys](/vault/docs/secrets/kv/kv-v2/cookbook/read-subkey) - [Soft delete data](/vault/docs/secrets/kv/kv-v2/cookbook/delete-data) - [Restore soft deleted data](/vault/docs/secrets/kv/kv-v2/cookbook/undelete-data) - [Destroy data](/vault/docs/secrets/kv/kv-v2/cookbook/destroy-data) diff --git a/website/content/docs/secrets/kv/kv-v2/random-string.mdx b/website/content/docs/secrets/kv/kv-v2/random-string.mdx index 858ae231c00c..6f9a79fbb5fc 100644 --- a/website/content/docs/secrets/kv/kv-v2/random-string.mdx +++ b/website/content/docs/secrets/kv/kv-v2/random-string.mdx @@ -268,9 +268,10 @@ g0bc0b6W3ii^SXa@*ie5 -@include 'gui-page-instructions/select-kv-mount.mdx' +@include 'gui-instructions/plugins/kv/open-overview.mdx' + +- Select the **Secret** tab. -- Click through the path segments to select the relevant secret path. - Click the eye icon to view the desired key value. ![Partial screenshot of the Vault GUI showing the randomized string stored at the path dev/seeds.](/img/gui/kv/random-string.png) diff --git a/website/content/docs/secrets/kv/kv-v2/setup.mdx b/website/content/docs/secrets/kv/kv-v2/setup.mdx index 1d386496450a..1d7e2c7d1fd8 100644 --- a/website/content/docs/secrets/kv/kv-v2/setup.mdx +++ b/website/content/docs/secrets/kv/kv-v2/setup.mdx @@ -52,7 +52,7 @@ $ vault secrets enable -path kv-v2 -@include 'gui-page-instructions/enable-secrets-plugin.mdx' +@include 'gui-instructions/enable-secrets-plugin.mdx' - Select the "KV" plugin. @@ -221,7 +221,7 @@ $ vault policy write "KV-access-policy" ./kv-policy.hcl -@include 'gui-page-instructions/create-acl-policy.mdx' +@include 'gui-instructions/create-acl-policy.mdx' - Provide a name for the policy and upload the policy definition file. diff --git a/website/content/partials/gui-page-instructions/create-acl-policy.mdx b/website/content/partials/gui-instructions/create-acl-policy.mdx similarity index 100% rename from website/content/partials/gui-page-instructions/create-acl-policy.mdx rename to website/content/partials/gui-instructions/create-acl-policy.mdx diff --git a/website/content/partials/gui-page-instructions/create-group.mdx b/website/content/partials/gui-instructions/create-group.mdx similarity index 100% rename from website/content/partials/gui-page-instructions/create-group.mdx rename to website/content/partials/gui-instructions/create-group.mdx diff --git a/website/content/partials/gui-page-instructions/enable-authn-plugin.mdx b/website/content/partials/gui-instructions/enable-authn-plugin.mdx similarity index 100% rename from website/content/partials/gui-page-instructions/enable-authn-plugin.mdx rename to website/content/partials/gui-instructions/enable-authn-plugin.mdx diff --git a/website/content/partials/gui-page-instructions/enable-secrets-plugin.mdx b/website/content/partials/gui-instructions/enable-secrets-plugin.mdx similarity index 100% rename from website/content/partials/gui-page-instructions/enable-secrets-plugin.mdx rename to website/content/partials/gui-instructions/enable-secrets-plugin.mdx diff --git a/website/content/partials/gui-page-instructions/select-kv-data.mdx b/website/content/partials/gui-instructions/plugins/kv/open-overview.mdx similarity index 81% rename from website/content/partials/gui-page-instructions/select-kv-data.mdx rename to website/content/partials/gui-instructions/plugins/kv/open-overview.mdx index 564b39ac9a0c..b45917a0b93a 100644 --- a/website/content/partials/gui-page-instructions/select-kv-data.mdx +++ b/website/content/partials/gui-instructions/plugins/kv/open-overview.mdx @@ -1,4 +1,4 @@ -- Open the data page for your `kv` plugin: +- Open the **Overview** screen for your secret path: 1. Open the GUI for your Vault instance. @@ -9,6 +9,4 @@ 1. Select the mount path for your `kv` plugin. - 1. Click through the path segments to select the relevant secret path. - - 1. Select the **Secret** tab \ No newline at end of file + 1. Click through the path segments to select the relevant secret path. \ No newline at end of file diff --git a/website/content/partials/gui-page-instructions/select-kv-mount.mdx b/website/content/partials/gui-page-instructions/select-kv-mount.mdx deleted file mode 100644 index aa162c153fb4..000000000000 --- a/website/content/partials/gui-page-instructions/select-kv-mount.mdx +++ /dev/null @@ -1,10 +0,0 @@ -- Open the data page for your `kv` plugin: - - 1. Open the GUI for your Vault instance. - - 1. Login under the namespace for the plugin or select the namespace from the - selector at the bottom of the left-hand menu and re-authenticate. - - 1. Select **Secrets Engines** from the left-hand menu. - - 1. Select the mount path for your `kv` plugin. \ No newline at end of file diff --git a/website/public/img/gui/kv/overview-page.png b/website/public/img/gui/kv/overview-page.png new file mode 100644 index 0000000000000000000000000000000000000000..cc2b09eaf52e184a255339a7ef8a83cec99c6206 GIT binary patch literal 212503 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 literal 0 HcmV?d00001 diff --git a/website/public/img/gui/kv/patch-reveal-subkeys.png b/website/public/img/gui/kv/patch-reveal-subkeys.png new file mode 100644 index 0000000000000000000000000000000000000000..e75b73e4c97fdfbce1c261586618adfd5c0e11ea GIT binary patch literal 179959 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 literal 0 HcmV?d00001 From 57e617f6aaf20f28d98b8fed769caa99306f7840 Mon Sep 17 00:00:00 2001 From: Violet Hynes Date: Thu, 5 Dec 2024 14:45:10 -0500 Subject: [PATCH 19/45] VAULT-32159 CE changes for PKI metrics (#29103) * VAULT-32159 CE changes for PKI metrics * Whoops, printf --- vault/core_metrics.go | 62 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/vault/core_metrics.go b/vault/core_metrics.go index 60f0295d144f..b56f0766c201 100644 --- a/vault/core_metrics.go +++ b/vault/core_metrics.go @@ -491,6 +491,68 @@ func (c *Core) walkKvMountSecrets(ctx context.Context, m *kvMount) { } } +// GetTotalPkiRoles returns the total roles across all PKI mounts in Vault +func (c *Core) GetTotalPkiRoles(ctx context.Context) int { + c.mountsLock.RLock() + defer c.mountsLock.RUnlock() + + numRoles := 0 + + for _, entry := range c.mounts.Entries { + secretType := entry.Type + if secretType == pluginconsts.SecretEnginePki { + listRequest := &logical.Request{ + Operation: logical.ListOperation, + Path: entry.namespace.Path + entry.Path + "roles", + } + resp, err := c.router.Route(ctx, listRequest) + if err != nil || resp == nil { + continue + } + rawKeys, ok := resp.Data["keys"] + if !ok { + continue + } + keys, ok := rawKeys.([]string) + if ok { + numRoles += len(keys) + } + } + } + return numRoles +} + +// GetTotalPkiCerts returns the total certs across all PKI mounts in Vault +func (c *Core) GetTotalPkiCerts(ctx context.Context) int { + c.mountsLock.RLock() + defer c.mountsLock.RUnlock() + + numRoles := 0 + + for _, entry := range c.mounts.Entries { + secretType := entry.Type + if secretType == pluginconsts.SecretEnginePki { + listRequest := &logical.Request{ + Operation: logical.ListOperation, + Path: entry.namespace.Path + entry.Path + "certs", + } + resp, err := c.router.Route(ctx, listRequest) + if err != nil || resp == nil { + continue + } + rawKeys, ok := resp.Data["keys"] + if !ok { + continue + } + keys, ok := rawKeys.([]string) + if ok { + numRoles += len(keys) + } + } + } + return numRoles +} + // getMinNamespaceSecrets is expected to be called on the output // of GetKvUsageMetrics to get the min number of secrets in a single namespace. func getMinNamespaceSecrets(mapOfNamespacesToSecrets map[string]int) int { From 7193292fc922415a9de60e56c6361da025cb0104 Mon Sep 17 00:00:00 2001 From: Violet Hynes Date: Thu, 5 Dec 2024 15:30:57 -0500 Subject: [PATCH 20/45] VAULT-32159 docs for pki metrics (#29102) * VAULT-32159 docs for pki metrics * Issuers, not certs --- .../docs/enterprise/license/product-usage-reporting.mdx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/website/content/docs/enterprise/license/product-usage-reporting.mdx b/website/content/docs/enterprise/license/product-usage-reporting.mdx index 1b855f11f128..cc6331f43f44 100644 --- a/website/content/docs/enterprise/license/product-usage-reporting.mdx +++ b/website/content/docs/enterprise/license/product-usage-reporting.mdx @@ -130,7 +130,7 @@ All of these metrics are numerical, and contain no sensitive values or additiona | `vault.auth.method.gcp.count` | The total number of GCP auth mounts in Vault. | | `vault.auth.method.jwt.count` | The total number of JWT auth mounts in Vault. | | `vault.auth.method.kerberos.count` | The total number of Kerberos auth mounts in Vault. | -| `vault.auth.method.kubernetes.count` | The total number of kubernetes auth mounts in Vault. | +| `vault.auth.method.kubernetes.count` | The total number of Kubernetes auth mounts in Vault. | | `vault.auth.method.ldap.count` | The total number of LDAP auth mounts in Vault. | | `vault.auth.method.oci.count` | The total number of OCI auth mounts in Vault. | | `vault.auth.method.okta.count` | The total number of Okta auth mounts in Vault. | @@ -152,7 +152,7 @@ All of these metrics are numerical, and contain no sensitive values or additiona | `vault.secret.engine.kubernetes.count` | The total number of Kubernetes secret engines in Vault. | | `vault.secret.engine.cassandra.count` | The total number of Cassandra secret engines in Vault. | | `vault.secret.engine.keymgmt.count` | The total number of Keymgmt secret engines in Vault. | -| `vault.secret.engine.kv.count` | The total number of kv secret engines in Vault. | +| `vault.secret.engine.kv.count` | The total number of KV secret engines in Vault. | | `vault.secret.engine.kmip.count` | The total number of KMIP secret engines in Vault. | | `vault.secret.engine.mongodb.count` | The total number of MongoDB secret engines in Vault. | | `vault.secret.engine.mongodbatlas.count` | The total number of MongoDBAtlas secret engines in Vault. | @@ -180,6 +180,8 @@ All of these metrics are numerical, and contain no sensitive values or additiona | `vault.secretsync.destinations.terraform.count` | The total number of Terraform secret destinations configured for secret sync. | | `vault.secretsync.destinations.gitlab.count` | The total number of GitLab secret destinations configured for secret sync. | | `vault.secretsync.destinations.inmem.count` | The total number of InMem secret destinations configured for secret sync. | +| `vault.pki.roles.count` | The total roles in all PKI mounts across all namespaces. | +| `vault.pki.issuers.count` | The total issuers from all PKI mounts across all namespaces. | ## Usage metadata list From a06b14b12065c19aaa21a018e1c99dd9a235d643 Mon Sep 17 00:00:00 2001 From: Violet Hynes Date: Thu, 5 Dec 2024 15:57:22 -0500 Subject: [PATCH 21/45] VAULT-32159 issuers not certs CE changes (#29105) * VAULT-32159 issuers not certs CE changes * Typo --- vault/core_metrics.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vault/core_metrics.go b/vault/core_metrics.go index b56f0766c201..c302099953a7 100644 --- a/vault/core_metrics.go +++ b/vault/core_metrics.go @@ -522,8 +522,8 @@ func (c *Core) GetTotalPkiRoles(ctx context.Context) int { return numRoles } -// GetTotalPkiCerts returns the total certs across all PKI mounts in Vault -func (c *Core) GetTotalPkiCerts(ctx context.Context) int { +// GetTotalPkiIssuers returns the total issuers across all PKI mounts in Vault +func (c *Core) GetTotalPkiIssuers(ctx context.Context) int { c.mountsLock.RLock() defer c.mountsLock.RUnlock() @@ -534,7 +534,7 @@ func (c *Core) GetTotalPkiCerts(ctx context.Context) int { if secretType == pluginconsts.SecretEnginePki { listRequest := &logical.Request{ Operation: logical.ListOperation, - Path: entry.namespace.Path + entry.Path + "certs", + Path: entry.namespace.Path + entry.Path + "issuers", } resp, err := c.router.Route(ctx, listRequest) if err != nil || resp == nil { From d515cd33b0ba5a719ff5a8d4415d80dbf775e4c9 Mon Sep 17 00:00:00 2001 From: kpcraig <3031348+kpcraig@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:21:30 -0500 Subject: [PATCH 22/45] VAULT-32598: add docs for azure autosnapshot auth modes (#29073) --- .../content/api-docs/system/storage/raftautosnapshots.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/website/content/api-docs/system/storage/raftautosnapshots.mdx b/website/content/api-docs/system/storage/raftautosnapshots.mdx index 5cf629593ecf..c9f23f3544bb 100644 --- a/website/content/api-docs/system/storage/raftautosnapshots.mdx +++ b/website/content/api-docs/system/storage/raftautosnapshots.mdx @@ -140,7 +140,11 @@ parameters in the context of AWS EKS & S3 configuration. - `azure_account_name` `(string)` - Azure account name. -- `azure_account_key` `(string)` - Azure account key. +- `azure_account_key` `(string)` - Azure account key. Used for `shared` authentication. + +- `azure_client_id` `(string)` - Azure Client ID. Used for `managed` authentication. + +- `azure_auth_mode` `(string)` - One of `shared` or `managed`. - `azure_blob_environment` `(string)` - Azure blob environment. From d8482b008a056543584d82fffea379d5cebbe307 Mon Sep 17 00:00:00 2001 From: kpcraig <3031348+kpcraig@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:22:21 -0500 Subject: [PATCH 23/45] VAULT-32804: Add STS Fallback parameters to secrets-aws engine (#29051) Co-authored-by: Sarah Chavis <62406755+schavis@users.noreply.github.com> --------- Co-authored-by: Robert <17119716+robmonte@users.noreply.github.com> Co-authored-by: Sarah Chavis <62406755+schavis@users.noreply.github.com> --- builtin/logical/aws/client.go | 199 ++++++++++++------- builtin/logical/aws/path_config_root.go | 73 ++++--- builtin/logical/aws/path_config_root_test.go | 148 ++++++++++++++ changelog/29051.txt | 3 + website/content/api-docs/secret/aws.mdx | 6 + 5 files changed, 335 insertions(+), 94 deletions(-) create mode 100644 changelog/29051.txt diff --git a/builtin/logical/aws/client.go b/builtin/logical/aws/client.go index 802abb3d1db7..4891666eae88 100644 --- a/builtin/logical/aws/client.go +++ b/builtin/logical/aws/client.go @@ -5,6 +5,7 @@ package aws import ( "context" + "errors" "fmt" "os" "strconv" @@ -23,91 +24,139 @@ import ( "github.com/hashicorp/vault/sdk/logical" ) +// Return a slice of *aws.Config, based on descending configuration priority. STS endpoints are the only place this is used. // NOTE: The caller is required to ensure that b.clientMutex is at least read locked -func (b *backend) getRootConfig(ctx context.Context, s logical.Storage, clientType string, logger hclog.Logger) (*aws.Config, error) { - credsConfig := &awsutil.CredentialsConfig{} - var endpoint string - var maxRetries int = aws.UseServiceDefaultRetries +func (b *backend) getRootConfigs(ctx context.Context, s logical.Storage, clientType string, logger hclog.Logger) ([]*aws.Config, error) { + // set fallback region (we can overwrite later) + fallbackRegion := os.Getenv("AWS_REGION") + if fallbackRegion == "" { + fallbackRegion = os.Getenv("AWS_DEFAULT_REGION") + } + if fallbackRegion == "" { + fallbackRegion = "us-east-1" + } + + maxRetries := aws.UseServiceDefaultRetries entry, err := s.Get(ctx, "config/root") if err != nil { return nil, err } - if entry != nil { - var config rootConfig - if err := entry.DecodeJSON(&config); err != nil { - return nil, fmt.Errorf("error reading root configuration: %w", err) + var configs []*aws.Config + + // ensure the nil case uses defaults + if entry == nil { + ccfg := awsutil.CredentialsConfig{ + HTTPClient: cleanhttp.DefaultClient(), + Logger: logger, + Region: fallbackRegion, + } + creds, err := ccfg.GenerateCredentialChain() + if err != nil { + return nil, err + } + configs = append(configs, &aws.Config{ + Credentials: creds, + Region: aws.String(fallbackRegion), + Endpoint: aws.String(""), + MaxRetries: aws.Int(maxRetries), + }) + + return configs, nil + } + + var config rootConfig + if err := entry.DecodeJSON(&config); err != nil { + return nil, fmt.Errorf("error reading root configuration: %w", err) + } + + var endpoints []string + var regions []string + credsConfig := &awsutil.CredentialsConfig{} + + credsConfig.AccessKey = config.AccessKey + credsConfig.SecretKey = config.SecretKey + credsConfig.HTTPClient = cleanhttp.DefaultClient() + credsConfig.Logger = logger + + maxRetries = config.MaxRetries + if clientType == "iam" && config.IAMEndpoint != "" { + endpoints = append(endpoints, config.IAMEndpoint) + } else if clientType == "sts" && config.STSEndpoint != "" { + endpoints = append(endpoints, config.STSEndpoint) + if config.STSRegion != "" { + regions = append(regions, config.STSRegion) } - credsConfig.AccessKey = config.AccessKey - credsConfig.SecretKey = config.SecretKey - credsConfig.Region = config.Region - maxRetries = config.MaxRetries - switch { - case clientType == "iam" && config.IAMEndpoint != "": - endpoint = *aws.String(config.IAMEndpoint) - case clientType == "sts" && config.STSEndpoint != "": - endpoint = *aws.String(config.STSEndpoint) - if config.STSRegion != "" { - credsConfig.Region = config.STSRegion - } + if len(config.STSFallbackEndpoints) > 0 { + endpoints = append(endpoints, config.STSFallbackEndpoints...) } - if config.IdentityTokenAudience != "" { - ns, err := namespace.FromContext(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get namespace from context: %w", err) - } - - fetcher := &PluginIdentityTokenFetcher{ - sys: b.System(), - logger: b.Logger(), - ns: ns, - audience: config.IdentityTokenAudience, - ttl: config.IdentityTokenTTL, - } - - sessionSuffix := strconv.FormatInt(time.Now().UnixNano(), 10) - credsConfig.RoleSessionName = fmt.Sprintf("vault-aws-secrets-%s", sessionSuffix) - credsConfig.WebIdentityTokenFetcher = fetcher - credsConfig.RoleARN = config.RoleARN + if len(config.STSFallbackRegions) > 0 { + regions = append(regions, config.STSFallbackRegions...) } } - if credsConfig.Region == "" { - credsConfig.Region = os.Getenv("AWS_REGION") - if credsConfig.Region == "" { - credsConfig.Region = os.Getenv("AWS_DEFAULT_REGION") - if credsConfig.Region == "" { - credsConfig.Region = "us-east-1" - } + if config.IdentityTokenAudience != "" { + ns, err := namespace.FromContext(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get namespace from context: %w", err) } + + fetcher := &PluginIdentityTokenFetcher{ + sys: b.System(), + logger: b.Logger(), + ns: ns, + audience: config.IdentityTokenAudience, + ttl: config.IdentityTokenTTL, + } + + sessionSuffix := strconv.FormatInt(time.Now().UnixNano(), 10) + credsConfig.RoleSessionName = fmt.Sprintf("vault-aws-secrets-%s", sessionSuffix) + credsConfig.WebIdentityTokenFetcher = fetcher + credsConfig.RoleARN = config.RoleARN } - credsConfig.HTTPClient = cleanhttp.DefaultClient() + if len(regions) == 0 { + regions = append(regions, fallbackRegion) + } - credsConfig.Logger = logger + if len(regions) != len(endpoints) { + // this probably can't happen, if the input was checked correctly + return nil, errors.New("number of regions does not match number of endpoints") + } - creds, err := credsConfig.GenerateCredentialChain() - if err != nil { - return nil, err + for i := 0; i < len(endpoints); i++ { + if len(regions) > i { + credsConfig.Region = regions[i] + } else { + credsConfig.Region = fallbackRegion + } + creds, err := credsConfig.GenerateCredentialChain() + if err != nil { + return nil, err + } + configs = append(configs, &aws.Config{ + Credentials: creds, + Region: aws.String(credsConfig.Region), + Endpoint: aws.String(endpoints[i]), + MaxRetries: aws.Int(maxRetries), + HTTPClient: cleanhttp.DefaultClient(), + }) } - return &aws.Config{ - Credentials: creds, - Region: aws.String(credsConfig.Region), - Endpoint: &endpoint, - HTTPClient: cleanhttp.DefaultClient(), - MaxRetries: aws.Int(maxRetries), - }, nil + return configs, nil } func (b *backend) nonCachedClientIAM(ctx context.Context, s logical.Storage, logger hclog.Logger) (*iam.IAM, error) { - awsConfig, err := b.getRootConfig(ctx, s, "iam", logger) + awsConfig, err := b.getRootConfigs(ctx, s, "iam", logger) if err != nil { return nil, err } - sess, err := session.NewSession(awsConfig) + if len(awsConfig) != 1 { + return nil, errors.New("could not obtain aws config") + } + sess, err := session.NewSession(awsConfig[0]) if err != nil { return nil, err } @@ -119,19 +168,33 @@ func (b *backend) nonCachedClientIAM(ctx context.Context, s logical.Storage, log } func (b *backend) nonCachedClientSTS(ctx context.Context, s logical.Storage, logger hclog.Logger) (*sts.STS, error) { - awsConfig, err := b.getRootConfig(ctx, s, "sts", logger) - if err != nil { - return nil, err - } - sess, err := session.NewSession(awsConfig) + awsConfig, err := b.getRootConfigs(ctx, s, "sts", logger) if err != nil { return nil, err } - client := sts.New(sess) - if client == nil { - return nil, fmt.Errorf("could not obtain sts client") + + var client *sts.STS + + for _, cfg := range awsConfig { + sess, err := session.NewSession(cfg) + if err != nil { + return nil, err + } + client = sts.New(sess) + if client == nil { + return nil, fmt.Errorf("could not obtain sts client") + } + + // ping the client - we only care about errors + _, err = client.GetCallerIdentity(&sts.GetCallerIdentityInput{}) + if err == nil { + return client, nil + } else { + b.Logger().Debug("couldn't connect with config trying next", "failed endpoint", cfg.Endpoint, "failed region", cfg.Region) + } } - return client, nil + + return nil, fmt.Errorf("could not obtain sts client") } // PluginIdentityTokenFetcher fetches plugin identity tokens from Vault. It is provided diff --git a/builtin/logical/aws/path_config_root.go b/builtin/logical/aws/path_config_root.go index 741c8502d08c..84b2f92fa555 100644 --- a/builtin/logical/aws/path_config_root.go +++ b/builtin/logical/aws/path_config_root.go @@ -52,6 +52,14 @@ func pathConfigRoot(b *backend) *framework.Path { Type: framework.TypeString, Description: "Specific region for STS API calls.", }, + "sts_fallback_endpoints": { + Type: framework.TypeCommaStringSlice, + Description: "Fallback endpoints if sts_endpoint is unreachable", + }, + "sts_fallback_regions": { + Type: framework.TypeCommaStringSlice, + Description: "Fallback regions if sts_region is unreachable", + }, "max_retries": { Type: framework.TypeInt, Default: aws.UseServiceDefaultRetries, @@ -110,14 +118,16 @@ func (b *backend) pathConfigRootRead(ctx context.Context, req *logical.Request, } configData := map[string]interface{}{ - "access_key": config.AccessKey, - "region": config.Region, - "iam_endpoint": config.IAMEndpoint, - "sts_endpoint": config.STSEndpoint, - "sts_region": config.STSRegion, - "max_retries": config.MaxRetries, - "username_template": config.UsernameTemplate, - "role_arn": config.RoleARN, + "access_key": config.AccessKey, + "region": config.Region, + "iam_endpoint": config.IAMEndpoint, + "sts_endpoint": config.STSEndpoint, + "sts_region": config.STSRegion, + "sts_fallback_endpoints": config.STSFallbackEndpoints, + "sts_fallback_regions": config.STSFallbackRegions, + "max_retries": config.MaxRetries, + "username_template": config.UsernameTemplate, + "role_arn": config.RoleARN, } config.PopulatePluginIdentityTokenData(configData) @@ -138,19 +148,28 @@ func (b *backend) pathConfigRootWrite(ctx context.Context, req *logical.Request, usernameTemplate = defaultUserNameTemplate } + stsFallbackEndpoints := data.Get("sts_fallback_endpoints").([]string) + stsFallbackRegions := data.Get("sts_fallback_regions").([]string) + + if len(stsFallbackEndpoints) != len(stsFallbackRegions) { + return logical.ErrorResponse("fallback endpoints and fallback regions must be the same length"), nil + } + b.clientMutex.Lock() defer b.clientMutex.Unlock() rc := rootConfig{ - AccessKey: data.Get("access_key").(string), - SecretKey: data.Get("secret_key").(string), - IAMEndpoint: iamendpoint, - STSEndpoint: stsendpoint, - STSRegion: stsregion, - Region: region, - MaxRetries: maxretries, - UsernameTemplate: usernameTemplate, - RoleARN: roleARN, + AccessKey: data.Get("access_key").(string), + SecretKey: data.Get("secret_key").(string), + IAMEndpoint: iamendpoint, + STSEndpoint: stsendpoint, + STSRegion: stsregion, + STSFallbackEndpoints: stsFallbackEndpoints, + STSFallbackRegions: stsFallbackRegions, + Region: region, + MaxRetries: maxretries, + UsernameTemplate: usernameTemplate, + RoleARN: roleARN, } if err := rc.ParsePluginIdentityTokenFields(data); err != nil { return logical.ErrorResponse(err.Error()), nil @@ -196,15 +215,17 @@ func (b *backend) pathConfigRootWrite(ctx context.Context, req *logical.Request, type rootConfig struct { pluginidentityutil.PluginIdentityTokenParams - AccessKey string `json:"access_key"` - SecretKey string `json:"secret_key"` - IAMEndpoint string `json:"iam_endpoint"` - STSEndpoint string `json:"sts_endpoint"` - STSRegion string `json:"sts_region"` - Region string `json:"region"` - MaxRetries int `json:"max_retries"` - UsernameTemplate string `json:"username_template"` - RoleARN string `json:"role_arn"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + IAMEndpoint string `json:"iam_endpoint"` + STSEndpoint string `json:"sts_endpoint"` + STSRegion string `json:"sts_region"` + STSFallbackEndpoints []string `json:"sts_fallback_endpoints"` + STSFallbackRegions []string `json:"sts_fallback_regions"` + Region string `json:"region"` + MaxRetries int `json:"max_retries"` + UsernameTemplate string `json:"username_template"` + RoleARN string `json:"role_arn"` } const pathConfigRootHelpSyn = ` diff --git a/builtin/logical/aws/path_config_root_test.go b/builtin/logical/aws/path_config_root_test.go index 9c1ed0476f3a..1439a8b5ce21 100644 --- a/builtin/logical/aws/path_config_root_test.go +++ b/builtin/logical/aws/path_config_root_test.go @@ -31,6 +31,8 @@ func TestBackend_PathConfigRoot(t *testing.T) { "iam_endpoint": "https://iam.amazonaws.com", "sts_endpoint": "https://sts.us-west-2.amazonaws.com", "sts_region": "", + "sts_fallback_endpoints": []string{}, + "sts_fallback_regions": []string{}, "max_retries": 10, "username_template": defaultUserNameTemplate, "role_arn": "", @@ -66,6 +68,152 @@ func TestBackend_PathConfigRoot(t *testing.T) { } } +// TestBackend_PathConfigRoot_STSFallback tests valid versions of STS fallback parameters - slice and csv +func TestBackend_PathConfigRoot_STSFallback(t *testing.T) { + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + config.System = &testSystemView{} + + b := Backend(config) + if err := b.Setup(context.Background(), config); err != nil { + t.Fatal(err) + } + + configData := map[string]interface{}{ + "access_key": "AKIAEXAMPLE", + "secret_key": "RandomData", + "region": "us-west-2", + "iam_endpoint": "https://iam.amazonaws.com", + "sts_endpoint": "https://sts.us-west-2.amazonaws.com", + "sts_region": "", + "sts_fallback_endpoints": []string{"192.168.1.1", "127.0.0.1"}, + "sts_fallback_regions": []string{"my-house-1", "my-house-2"}, + "max_retries": 10, + "username_template": defaultUserNameTemplate, + "role_arn": "", + "identity_token_audience": "", + "identity_token_ttl": int64(0), + } + + configReq := &logical.Request{ + Operation: logical.UpdateOperation, + Storage: config.StorageView, + Path: "config/root", + Data: configData, + } + + resp, err := b.HandleRequest(context.Background(), configReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: config writing failed: resp:%#v\n err: %v", resp, err) + } + + resp, err = b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Storage: config.StorageView, + Path: "config/root", + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: config reading failed: resp:%#v\n err: %v", resp, err) + } + + delete(configData, "secret_key") + require.Equal(t, configData, resp.Data) + if !reflect.DeepEqual(resp.Data, configData) { + t.Errorf("bad: expected to read config root as %#v, got %#v instead", configData, resp.Data) + } + + // test we can handle comma separated strings, per CommaStringSlice + configData = map[string]interface{}{ + "access_key": "AKIAEXAMPLE", + "secret_key": "RandomData", + "region": "us-west-2", + "iam_endpoint": "https://iam.amazonaws.com", + "sts_endpoint": "https://sts.us-west-2.amazonaws.com", + "sts_region": "", + "sts_fallback_endpoints": "1.1.1.1,8.8.8.8", + "sts_fallback_regions": "zone-1,zone-2", + "max_retries": 10, + "username_template": defaultUserNameTemplate, + "role_arn": "", + "identity_token_audience": "", + "identity_token_ttl": int64(0), + } + + configReq = &logical.Request{ + Operation: logical.UpdateOperation, + Storage: config.StorageView, + Path: "config/root", + Data: configData, + } + + resp, err = b.HandleRequest(context.Background(), configReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: config writing failed: resp:%#v\n err: %v", resp, err) + } + + resp, err = b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Storage: config.StorageView, + Path: "config/root", + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: config reading failed: resp:%#v\n err: %v", resp, err) + } + + delete(configData, "secret_key") + configData["sts_fallback_endpoints"] = []string{"1.1.1.1", "8.8.8.8"} + configData["sts_fallback_regions"] = []string{"zone-1", "zone-2"} + require.Equal(t, configData, resp.Data) + if !reflect.DeepEqual(resp.Data, configData) { + t.Errorf("bad: expected to read config root as %#v, got %#v instead", configData, resp.Data) + } +} + +// TestBackend_PathConfigRoot_STSFallback_mismatchedfallback ensures configuration writing will fail if the +// region/endpoint entries are different lengths +func TestBackend_PathConfigRoot_STSFallback_mismatchedfallback(t *testing.T) { + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + config.System = &testSystemView{} + + b := Backend(config) + if err := b.Setup(context.Background(), config); err != nil { + t.Fatal(err) + } + + // test we can handle comma separated strings, per CommaStringSlice + configData := map[string]interface{}{ + "access_key": "AKIAEXAMPLE", + "secret_key": "RandomData", + "region": "us-west-2", + "iam_endpoint": "https://iam.amazonaws.com", + "sts_endpoint": "https://sts.us-west-2.amazonaws.com", + "sts_region": "", + "sts_fallback_endpoints": "1.1.1.1,8.8.8.8", + "sts_fallback_regions": "zone-1,zone-2", + "max_retries": 10, + "username_template": defaultUserNameTemplate, + "role_arn": "", + "identity_token_audience": "", + "identity_token_ttl": int64(0), + } + + configReq := &logical.Request{ + Operation: logical.UpdateOperation, + Storage: config.StorageView, + Path: "config/root", + Data: configData, + } + + resp, err := b.HandleRequest(context.Background(), configReq) + if err != nil { + t.Fatalf("bad: config writing failed: err: %v", err) + } + if resp != nil && !resp.IsError() { + t.Fatalf("expected an error, but it successfully wrote") + } +} + // TestBackend_PathConfigRoot_PluginIdentityToken tests that configuration // of plugin WIF returns an immediate error. func TestBackend_PathConfigRoot_PluginIdentityToken(t *testing.T) { diff --git a/changelog/29051.txt b/changelog/29051.txt new file mode 100644 index 000000000000..13c42006d9e5 --- /dev/null +++ b/changelog/29051.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secrets/aws: add fallback endpoint and region parameters to sts configuration +``` diff --git a/website/content/api-docs/secret/aws.mdx b/website/content/api-docs/secret/aws.mdx index f1c0959e3c71..9b34a2a63326 100644 --- a/website/content/api-docs/secret/aws.mdx +++ b/website/content/api-docs/secret/aws.mdx @@ -79,6 +79,12 @@ valid AWS credentials with proper permissions. - `sts_endpoint` `(string: )` – Specifies a custom HTTP STS endpoint to use. +- `sts_region` `(string: )` - Specifies a custom STS region to use (should match `sts_endpoint`) + +- `sts_fallback_endpoints` `(list: )` - Specifies an ordered list of fallback STS endpoints to use + +- `sts_fallback_regions` `(list: )` - Specifies an ordered list of fallback STS regions to use (should match fallback endpoints) + - `username_template` `(string: )` - [Template](/vault/docs/concepts/username-templating) describing how dynamic usernames are generated. The username template is used to generate both IAM usernames (capped at 64 characters) and STS usernames (capped at 32 characters). Longer usernames result in a 500 error. From 86ba0dbdebeb6d6acbe12c95c622dba838fc8a91 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 5 Dec 2024 15:39:16 -0600 Subject: [PATCH 24/45] Use go-secure-stdlib's RSA key generator backed by a DRBG (#29020) * Use DRBG based RSA key generation everywhere * switch to the conditional generator * Use DRBG based RSA key generation everywhere * switch to the conditional generator * Add an ENV var to disable the DRBG in a pinch * update go.mod * Use DRBG based RSA key generation everywhere * switch to the conditional generator * Add an ENV var to disable the DRBG in a pinch * Use DRBG based RSA key generation everywhere * update go.mod * fix import * Remove rsa2 alias, remove test code * move cryptoutil/rsa.go to sdk * move imports too * remove makefile change * rsa2->rsa * more rsa2->rsa, remove test code * fix some overzelous search/replace * Update to a real tag * changelog * copyright * work around copyright check * work around copyright check pt2 * bunch of dupe imports * missing import * wrong license * fix go.mod conflict * missed a spot * dupe import --- builtin/credential/cert/backend_test.go | 6 ++-- builtin/logical/database/credentials.go | 5 +-- builtin/logical/pki/backend_test.go | 17 +++++----- builtin/logical/pki/ca_test.go | 4 +-- builtin/logical/pki/ca_util_test.go | 5 +-- builtin/logical/pki/path_acme_test.go | 21 ++++++------ builtin/logical/pki/path_config_acme_test.go | 4 +-- builtin/logical/pki/path_tidy_test.go | 6 ++-- .../logical/pkiext/pkiext_binary/acme_test.go | 6 ++-- builtin/logical/ssh/path_config_ca.go | 4 +-- builtin/logical/ssh/util.go | 5 +-- .../logical/transit/path_certificates_test.go | 4 +-- builtin/logical/transit/path_import_test.go | 11 +++--- changelog/29020.txt | 5 +++ command/transit_import_key_test.go | 6 ++-- go.mod | 2 ++ go.sum | 4 +++ .../testhelpers/certhelpers/cert_helpers.go | 4 ++- plugins/database/mongodb/cert_helpers_test.go | 4 ++- sdk/go.mod | 2 ++ sdk/go.sum | 4 +++ sdk/helper/certutil/certutil_test.go | 11 +++--- sdk/helper/certutil/helpers.go | 4 ++- sdk/helper/certutil/types_test.go | 5 +-- sdk/helper/cryptoutil/rsa.go | 34 +++++++++++++++++++ sdk/helper/keysutil/policy.go | 3 +- sdk/helper/keysutil/policy_test.go | 7 ++-- vault/identity_store_oidc.go | 4 +-- 28 files changed, 132 insertions(+), 65 deletions(-) create mode 100644 changelog/29020.txt create mode 100644 sdk/helper/cryptoutil/rsa.go diff --git a/builtin/credential/cert/backend_test.go b/builtin/credential/cert/backend_test.go index 0840362f0bdc..fb9eb8ca5a31 100644 --- a/builtin/credential/cert/backend_test.go +++ b/builtin/credential/cert/backend_test.go @@ -9,7 +9,6 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" - "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" @@ -39,6 +38,7 @@ import ( vaulthttp "github.com/hashicorp/vault/http" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/helper/certutil" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" "github.com/hashicorp/vault/sdk/helper/tokenutil" "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/vault" @@ -658,7 +658,7 @@ func TestBackend_NonCAExpiry(t *testing.T) { template.IPAddresses = []net.IP{parsedIP} // Private key for CA cert - caPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) + caPrivateKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } @@ -726,7 +726,7 @@ func TestBackend_NonCAExpiry(t *testing.T) { template.SerialNumber = big.NewInt(5678) template.KeyUsage = x509.KeyUsage(x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign) - issuedPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) + issuedPrivateKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } diff --git a/builtin/logical/database/credentials.go b/builtin/logical/database/credentials.go index 790dde05a35b..89e5b438a219 100644 --- a/builtin/logical/database/credentials.go +++ b/builtin/logical/database/credentials.go @@ -6,7 +6,6 @@ package database import ( "context" "crypto/rand" - "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" @@ -15,6 +14,8 @@ import ( "strings" "time" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" + "github.com/hashicorp/vault/helper/random" "github.com/hashicorp/vault/sdk/database/dbplugin/v5" "github.com/hashicorp/vault/sdk/helper/certutil" @@ -133,7 +134,7 @@ func (kg *rsaKeyGenerator) generate(r io.Reader) ([]byte, []byte, error) { return nil, nil, fmt.Errorf("invalid key_bits: %v", kg.KeyBits) } - key, err := rsa.GenerateKey(reader, keyBits) + key, err := cryptoutil.GenerateRSAKey(reader, keyBits) if err != nil { return nil, nil, err } diff --git a/builtin/logical/pki/backend_test.go b/builtin/logical/pki/backend_test.go index 3cdd73833eb5..5c38989f8f85 100644 --- a/builtin/logical/pki/backend_test.go +++ b/builtin/logical/pki/backend_test.go @@ -50,6 +50,7 @@ import ( "github.com/hashicorp/vault/helper/testhelpers/teststorage" vaulthttp "github.com/hashicorp/vault/http" "github.com/hashicorp/vault/sdk/helper/certutil" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" "github.com/hashicorp/vault/sdk/helper/testhelpers/schema" "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/vault" @@ -510,14 +511,14 @@ func generateURLSteps(t *testing.T, caCert, caKey string, intdata, reqdata map[s }, } - priv1024, _ := rsa.GenerateKey(rand.Reader, 1024) + priv1024, _ := cryptoutil.GenerateRSAKey(rand.Reader, 1024) csr1024, _ := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, priv1024) csrPem1024 := strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE REQUEST", Bytes: csr1024, }))) - priv2048, _ := rsa.GenerateKey(rand.Reader, 2048) + priv2048, _ := cryptoutil.GenerateRSAKey(rand.Reader, 2048) csr2048, _ := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, priv2048) csrPem2048 := strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE REQUEST", @@ -699,7 +700,7 @@ func generateCSR(t *testing.T, csrTemplate *x509.CertificateRequest, keyType str var err error switch keyType { case "rsa": - priv, err = rsa.GenerateKey(rand.Reader, keyBits) + priv, err = cryptoutil.GenerateRSAKey(rand.Reader, keyBits) case "ec": switch keyBits { case 224: @@ -1180,7 +1181,7 @@ func generateRoleSteps(t *testing.T, useCSRs bool) []logicaltest.TestStep { case "rsa": privKey, ok = generatedRSAKeys[keyBits] if !ok { - privKey, _ = rsa.GenerateKey(rand.Reader, keyBits) + privKey, _ = cryptoutil.GenerateRSAKey(rand.Reader, keyBits) generatedRSAKeys[keyBits] = privKey } @@ -2164,7 +2165,7 @@ func runTestSignVerbatim(t *testing.T, keyType string) { } // create a CSR and key - key, err := rsa.GenerateKey(rand.Reader, 2048) + key, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } @@ -2735,7 +2736,7 @@ func TestBackend_SignSelfIssued(t *testing.T) { t.Fatal(err) } - key, err := rsa.GenerateKey(rand.Reader, 2048) + key, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } @@ -2879,7 +2880,7 @@ func TestBackend_SignSelfIssued_DifferentTypes(t *testing.T) { t.Fatal(err) } - key, err := rsa.GenerateKey(rand.Reader, 2048) + key, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } @@ -3834,7 +3835,7 @@ func setCerts() { } ecCACert = strings.TrimSpace(string(pem.EncodeToMemory(caCertPEMBlock))) - rak, err := rsa.GenerateKey(rand.Reader, 2048) + rak, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) if err != nil { panic(err) } diff --git a/builtin/logical/pki/ca_test.go b/builtin/logical/pki/ca_test.go index 4517604f8a0d..069c5777b9dd 100644 --- a/builtin/logical/pki/ca_test.go +++ b/builtin/logical/pki/ca_test.go @@ -9,7 +9,6 @@ import ( "crypto/ed25519" "crypto/elliptic" "crypto/rand" - "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/json" @@ -24,6 +23,7 @@ import ( "github.com/hashicorp/vault/api" vaulthttp "github.com/hashicorp/vault/http" "github.com/hashicorp/vault/sdk/helper/certutil" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/vault" ) @@ -98,7 +98,7 @@ func TestBackend_CA_Steps(t *testing.T) { } ecCACert = strings.TrimSpace(string(pem.EncodeToMemory(caCertPEMBlock))) - rak, err := rsa.GenerateKey(rand.Reader, 2048) + rak, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) if err != nil { panic(err) } diff --git a/builtin/logical/pki/ca_util_test.go b/builtin/logical/pki/ca_util_test.go index d4ef64e68fe1..96d60e2c302d 100644 --- a/builtin/logical/pki/ca_util_test.go +++ b/builtin/logical/pki/ca_util_test.go @@ -9,14 +9,15 @@ import ( "crypto/ed25519" "crypto/elliptic" "crypto/rand" - "crypto/rsa" "testing" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" + "github.com/hashicorp/vault/sdk/helper/certutil" ) func TestGetKeyTypeAndBitsFromPublicKeyForRole(t *testing.T) { - rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + rsaKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) if err != nil { t.Fatalf("error generating rsa key: %s", err) } diff --git a/builtin/logical/pki/path_acme_test.go b/builtin/logical/pki/path_acme_test.go index 493a601c985e..ac16c36cc871 100644 --- a/builtin/logical/pki/path_acme_test.go +++ b/builtin/logical/pki/path_acme_test.go @@ -32,6 +32,7 @@ import ( "github.com/hashicorp/vault/helper/testhelpers" vaulthttp "github.com/hashicorp/vault/http" "github.com/hashicorp/vault/sdk/helper/certutil" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" "github.com/hashicorp/vault/sdk/helper/jsonutil" "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/vault" @@ -60,7 +61,7 @@ func TestAcmeBasicWorkflow(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { baseAcmeURL := "/v1/pki/" + tc.prefixUrl - accountKey, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa key") acmeClient := getAcmeClientForCluster(t, cluster, baseAcmeURL, accountKey) @@ -592,7 +593,7 @@ func TestAcmeAccountsCrossingDirectoryPath(t *testing.T) { defer cluster.Cleanup() baseAcmeURL := "/v1/pki/acme/" - accountKey, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa key") testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) @@ -628,7 +629,7 @@ func TestAcmeEabCrossingDirectoryPath(t *testing.T) { require.NoError(t, err) baseAcmeURL := "/v1/pki/acme/" - accountKey, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa key") testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) @@ -838,7 +839,7 @@ func TestAcmeTruncatesToIssuerExpiry(t *testing.T) { require.NoError(t, err, "failed updating issuer name") baseAcmeURL := "/v1/pki/issuer/short-ca/acme/" - accountKey, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa key") acmeClient := getAcmeClientForCluster(t, cluster, baseAcmeURL, accountKey) @@ -910,7 +911,7 @@ func TestAcmeRoleExtKeyUsage(t *testing.T) { _, err := client.Logical().Write("pki/roles/"+roleName, roleOpt) baseAcmeURL := "/v1/pki/roles/" + roleName + "/acme/" - accountKey, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa key") require.NoError(t, err, "failed creating role test-role") @@ -1179,7 +1180,7 @@ func TestAcmeWithCsrIncludingBasicConstraintExtension(t *testing.T) { defer cancel() baseAcmeURL := "/v1/pki/acme/" - accountKey, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa key") acmeClient := getAcmeClientForCluster(t, cluster, baseAcmeURL, accountKey) @@ -1511,7 +1512,7 @@ func TestAcmeValidationError(t *testing.T) { defer cancel() baseAcmeURL := "/v1/pki/acme/" - accountKey, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa key") acmeClient := getAcmeClientForCluster(t, cluster, baseAcmeURL, accountKey) @@ -1619,7 +1620,7 @@ func TestAcmeRevocationAcrossAccounts(t *testing.T) { defer cancel() baseAcmeURL := "/v1/pki/acme/" - accountKey1, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey1, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa key") acmeClient1 := getAcmeClientForCluster(t, cluster, baseAcmeURL, accountKey1) @@ -1718,7 +1719,7 @@ func TestAcmeMaxTTL(t *testing.T) { require.NoError(t, err, "error configuring acme") // First Create Our Client - accountKey, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa key") acmeClient := getAcmeClientForCluster(t, cluster, "/v1/pki/acme/", accountKey) @@ -1946,7 +1947,7 @@ func TestACMEClientRequestLimits(t *testing.T) { for _, tc := range cases { // First Create Our Client - accountKey, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa key") acmeClient := getAcmeClientForCluster(t, cluster, "/v1/pki/acme/", accountKey) diff --git a/builtin/logical/pki/path_config_acme_test.go b/builtin/logical/pki/path_config_acme_test.go index 47ba1f817dec..cfc7eeb03f19 100644 --- a/builtin/logical/pki/path_config_acme_test.go +++ b/builtin/logical/pki/path_config_acme_test.go @@ -6,11 +6,11 @@ package pki import ( "context" "crypto/rand" - "crypto/rsa" "testing" "time" "github.com/hashicorp/vault/helper/constants" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" "github.com/stretchr/testify/require" ) @@ -117,7 +117,7 @@ func TestAcmeConfig(t *testing.T) { require.NoError(t, err) baseAcmeURL := "/v1/pki/" + tc.prefixUrl - accountKey, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa key") acmeClient := getAcmeClientForCluster(t, cluster, baseAcmeURL, accountKey) diff --git a/builtin/logical/pki/path_tidy_test.go b/builtin/logical/pki/path_tidy_test.go index f32bc880a59e..911e050dd003 100644 --- a/builtin/logical/pki/path_tidy_test.go +++ b/builtin/logical/pki/path_tidy_test.go @@ -8,7 +8,6 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" - "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/json" @@ -23,6 +22,7 @@ import ( "github.com/hashicorp/vault/api" "github.com/hashicorp/vault/helper/testhelpers" vaulthttp "github.com/hashicorp/vault/http" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" "github.com/hashicorp/vault/sdk/helper/jsonutil" "github.com/hashicorp/vault/sdk/helper/testhelpers/schema" "github.com/hashicorp/vault/sdk/logical" @@ -916,7 +916,7 @@ func TestTidyAcmeWithBackdate(t *testing.T) { // Register an Account, do nothing with it baseAcmeURL := "/v1/pki/acme/" - accountKey, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa key") acmeClient := getAcmeClientForCluster(t, cluster, baseAcmeURL, accountKey) @@ -1073,7 +1073,7 @@ func TestTidyAcmeWithSafetyBuffer(t *testing.T) { // Register an Account, do nothing with it baseAcmeURL := "/v1/pki/acme/" - accountKey, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa key") acmeClient := getAcmeClientForCluster(t, cluster, baseAcmeURL, accountKey) diff --git a/builtin/logical/pkiext/pkiext_binary/acme_test.go b/builtin/logical/pkiext/pkiext_binary/acme_test.go index f4a7be0c1d83..dfacff875779 100644 --- a/builtin/logical/pkiext/pkiext_binary/acme_test.go +++ b/builtin/logical/pkiext/pkiext_binary/acme_test.go @@ -8,7 +8,6 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" - "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" @@ -30,6 +29,7 @@ import ( "github.com/hashicorp/vault/helper/testhelpers" "github.com/hashicorp/vault/helper/testhelpers/corehelpers" "github.com/hashicorp/vault/sdk/helper/certutil" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" hDocker "github.com/hashicorp/vault/sdk/helper/docker" "github.com/stretchr/testify/require" "golang.org/x/crypto/acme" @@ -704,7 +704,7 @@ func doAcmeValidationWithGoLibrary(t *testing.T, directoryUrl string, acmeOrderI } httpClient := &http.Client{Transport: tr} - accountKey, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa account key") logger.Trace("Using the following url for the ACME directory", "url", directoryUrl) acmeClient := &acme.Client{ @@ -957,7 +957,7 @@ func SubtestACMEStepDownNode(t *testing.T, cluster *VaultPkiCluster) { DNSNames: []string{hostname, hostname}, } - accountKey, err := rsa.GenerateKey(rand.Reader, 2048) + accountKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NoError(t, err, "failed creating rsa account key") acmeClient := &acme.Client{ diff --git a/builtin/logical/ssh/path_config_ca.go b/builtin/logical/ssh/path_config_ca.go index 6d003c0ae5c0..d0f0abae1309 100644 --- a/builtin/logical/ssh/path_config_ca.go +++ b/builtin/logical/ssh/path_config_ca.go @@ -10,7 +10,6 @@ import ( "crypto/ed25519" "crypto/elliptic" "crypto/rand" - "crypto/rsa" "crypto/x509" "encoding/pem" "errors" @@ -19,6 +18,7 @@ import ( multierror "github.com/hashicorp/go-multierror" "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" "github.com/hashicorp/vault/sdk/logical" "github.com/mikesmitty/edkey" "golang.org/x/crypto/ssh" @@ -326,7 +326,7 @@ func generateSSHKeyPair(randomSource io.Reader, keyType string, keyBits int) (st return "", "", fmt.Errorf("refusing to generate weak %v key: %v bits < 2048 bits", keyType, keyBits) } - privateSeed, err := rsa.GenerateKey(randomSource, keyBits) + privateSeed, err := cryptoutil.GenerateRSAKey(randomSource, keyBits) if err != nil { return "", "", err } diff --git a/builtin/logical/ssh/util.go b/builtin/logical/ssh/util.go index 89980ada0132..5ba5633761e5 100644 --- a/builtin/logical/ssh/util.go +++ b/builtin/logical/ssh/util.go @@ -6,7 +6,6 @@ package ssh import ( "context" "crypto/rand" - "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/pem" @@ -14,6 +13,8 @@ import ( "net" "strings" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" + "github.com/hashicorp/go-secure-stdlib/parseutil" "github.com/hashicorp/vault/sdk/logical" "golang.org/x/crypto/ssh" @@ -22,7 +23,7 @@ import ( // Creates a new RSA key pair with the given key length. The private key will be // of pem format and the public key will be of OpenSSH format. func generateRSAKeys(keyBits int) (publicKeyRsa string, privateKeyRsa string, err error) { - privateKey, err := rsa.GenerateKey(rand.Reader, keyBits) + privateKey, err := cryptoutil.GenerateRSAKey(rand.Reader, keyBits) if err != nil { return "", "", fmt.Errorf("error generating RSA key-pair: %w", err) } diff --git a/builtin/logical/transit/path_certificates_test.go b/builtin/logical/transit/path_certificates_test.go index 9a6305e7a048..b50916a07f81 100644 --- a/builtin/logical/transit/path_certificates_test.go +++ b/builtin/logical/transit/path_certificates_test.go @@ -6,7 +6,6 @@ package transit import ( "context" cryptoRand "crypto/rand" - "crypto/rsa" "crypto/x509" "encoding/pem" "fmt" @@ -17,6 +16,7 @@ import ( "github.com/hashicorp/vault/api" "github.com/hashicorp/vault/builtin/logical/pki" vaulthttp "github.com/hashicorp/vault/http" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/vault" "github.com/stretchr/testify/require" @@ -167,7 +167,7 @@ func testTransit_ImportCertChain(t *testing.T, apiClient *api.Client, keyType st require.NoError(t, err) // Setup a new CSR - privKey, err := rsa.GenerateKey(cryptoRand.Reader, 3072) + privKey, err := cryptoutil.GenerateRSAKey(cryptoRand.Reader, 3072) require.NoError(t, err) var csrTemplate x509.CertificateRequest diff --git a/builtin/logical/transit/path_import_test.go b/builtin/logical/transit/path_import_test.go index d26f5ff95f64..861f1fd67acc 100644 --- a/builtin/logical/transit/path_import_test.go +++ b/builtin/logical/transit/path_import_test.go @@ -20,6 +20,7 @@ import ( "testing" uuid "github.com/hashicorp/go-uuid" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" "github.com/hashicorp/vault/sdk/logical" "github.com/tink-crypto/tink-go/v2/kwp/subtle" ) @@ -162,7 +163,7 @@ func TestTransit_Import(t *testing.T) { t.Run( "import into a key fails before wrapping key is read", func(t *testing.T) { - fakeWrappingKey, err := rsa.GenerateKey(rand.Reader, 4096) + fakeWrappingKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 4096) if err != nil { t.Fatalf("failed to generate fake wrapping key: %s", err) } @@ -502,7 +503,7 @@ func TestTransit_ImportVersion(t *testing.T) { t.Run( "import into a key version fails before wrapping key is read", func(t *testing.T) { - fakeWrappingKey, err := rsa.GenerateKey(rand.Reader, 4096) + fakeWrappingKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 4096) if err != nil { t.Fatalf("failed to generate fake wrapping key: %s", err) } @@ -1027,11 +1028,11 @@ func generateKey(keyType string) (interface{}, error) { case "ecdsa-p521": return ecdsa.GenerateKey(elliptic.P521(), rand.Reader) case "rsa-2048": - return rsa.GenerateKey(rand.Reader, 2048) + return cryptoutil.GenerateRSAKey(rand.Reader, 2048) case "rsa-3072": - return rsa.GenerateKey(rand.Reader, 3072) + return cryptoutil.GenerateRSAKey(rand.Reader, 3072) case "rsa-4096": - return rsa.GenerateKey(rand.Reader, 4096) + return cryptoutil.GenerateRSAKey(rand.Reader, 4096) default: return nil, fmt.Errorf("failed to generate unsupported key type: %s", keyType) } diff --git a/changelog/29020.txt b/changelog/29020.txt new file mode 100644 index 000000000000..928e61d265f0 --- /dev/null +++ b/changelog/29020.txt @@ -0,0 +1,5 @@ +```release-note: improvement +sdk/helper: utitilize a randomly seeded cryptographic determinstic random bit generator for +RSA key generation when using slow random sources, speeding key generation +considerably. +``` diff --git a/command/transit_import_key_test.go b/command/transit_import_key_test.go index 847ab59ff78f..5994f91e3372 100644 --- a/command/transit_import_key_test.go +++ b/command/transit_import_key_test.go @@ -7,13 +7,13 @@ import ( "bytes" "context" "crypto/rand" - "crypto/rsa" "crypto/x509" "encoding/base64" "testing" "time" "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" "github.com/stretchr/testify/require" ) @@ -171,7 +171,7 @@ func execTransitImport(t *testing.T, client *api.Client, method string, path str func generateKeys(t *testing.T) (rsa1 []byte, rsa2 []byte, aes128 []byte, aes256 []byte) { t.Helper() - priv1, err := rsa.GenerateKey(rand.Reader, 2048) + priv1, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NotNil(t, priv1, "failed generating RSA 1 key") require.NoError(t, err, "failed generating RSA 1 key") @@ -179,7 +179,7 @@ func generateKeys(t *testing.T) (rsa1 []byte, rsa2 []byte, aes128 []byte, aes256 require.NotNil(t, rsa1, "failed marshaling RSA 1 key") require.NoError(t, err, "failed marshaling RSA 1 key") - priv2, err := rsa.GenerateKey(rand.Reader, 2048) + priv2, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) require.NotNil(t, priv2, "failed generating RSA 2 key") require.NoError(t, err, "failed generating RSA 2 key") diff --git a/go.mod b/go.mod index a56404602521..9b998cd305a3 100644 --- a/go.mod +++ b/go.mod @@ -100,6 +100,7 @@ require ( github.com/hashicorp/go-rootcerts v1.0.2 github.com/hashicorp/go-secure-stdlib/awsutil v0.3.0 github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 + github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.0 github.com/hashicorp/go-secure-stdlib/gatedwriter v0.1.1 github.com/hashicorp/go-secure-stdlib/kv-builder v0.1.2 github.com/hashicorp/go-secure-stdlib/mlock v0.1.3 @@ -237,6 +238,7 @@ require ( github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-viper/mapstructure/v2 v2.1.0 // indirect + github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6 // indirect github.com/hashicorp/go-secure-stdlib/httputil v0.1.0 // indirect github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect diff --git a/go.sum b/go.sum index d83f25ac44fb..8c38651d7f1b 100644 --- a/go.sum +++ b/go.sum @@ -1414,6 +1414,8 @@ github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39 github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6 h1:kBoJV4Xl5FLtBfnBjDvBxeNSy2IRITSGs73HQsFUEjY= +github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6/go.mod h1:y+HSOcOGB48PkUxNyLAiCiY6rEENu+E+Ss4LG8QHwf4= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= @@ -1461,6 +1463,8 @@ github.com/hashicorp/go-secure-stdlib/awsutil v0.3.0 h1:I8bynUKMh9I7JdwtW9voJ0xm github.com/hashicorp/go-secure-stdlib/awsutil v0.3.0/go.mod h1:oKHSQs4ivIfZ3fbXGQOop1XuDfdSb8RIsWTGaAanSfg= github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng= github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= +github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.0 h1:4B46+S65WqQUlp0rX2F7TX6/p0HmUZsDD+cVzFTwztw= +github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.0/go.mod h1:hH8rgXHh9fPSDPerG6WzABHsHF+9ZpLhRI1LPk4JZ8c= github.com/hashicorp/go-secure-stdlib/fileutil v0.1.0 h1:f2mwVgMJjXuX/+eWD6ZW30+oIRgCofL+XMWknFkB1WM= github.com/hashicorp/go-secure-stdlib/fileutil v0.1.0/go.mod h1:uwcr2oga9pN5+OkHZyTN5MDk3+1YHOuMukhpnPaQAoI= github.com/hashicorp/go-secure-stdlib/gatedwriter v0.1.1 h1:9um9R8i0+HbRHS9d64kdvWR0/LJvo12sIonvR9zr1+U= diff --git a/helper/testhelpers/certhelpers/cert_helpers.go b/helper/testhelpers/certhelpers/cert_helpers.go index d9c89735c618..e0b31fcc0e5b 100644 --- a/helper/testhelpers/certhelpers/cert_helpers.go +++ b/helper/testhelpers/certhelpers/cert_helpers.go @@ -18,6 +18,8 @@ import ( "strings" "testing" "time" + + "github.com/hashicorp/vault/sdk/helper/cryptoutil" ) type CertBuilder struct { @@ -166,7 +168,7 @@ type KeyWrapper struct { func NewPrivateKey(t *testing.T) (key KeyWrapper) { t.Helper() - privKey, err := rsa.GenerateKey(rand.Reader, 2048) + privKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) if err != nil { t.Fatalf("Unable to generate key for cert: %s", err) } diff --git a/plugins/database/mongodb/cert_helpers_test.go b/plugins/database/mongodb/cert_helpers_test.go index 3a8f3afcb84f..9082055c643c 100644 --- a/plugins/database/mongodb/cert_helpers_test.go +++ b/plugins/database/mongodb/cert_helpers_test.go @@ -17,6 +17,8 @@ import ( "strings" "testing" "time" + + "github.com/hashicorp/vault/sdk/helper/cryptoutil" ) type certBuilder struct { @@ -154,7 +156,7 @@ type keyWrapper struct { func newPrivateKey(t *testing.T) (key keyWrapper) { t.Helper() - privKey, err := rsa.GenerateKey(rand.Reader, 2048) + privKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) if err != nil { t.Fatalf("Unable to generate key for cert: %s", err) } diff --git a/sdk/go.mod b/sdk/go.mod index 68f13ce14ea7..8d306d850a4e 100644 --- a/sdk/go.mod +++ b/sdk/go.mod @@ -26,6 +26,7 @@ require ( github.com/hashicorp/go-plugin v1.6.1 github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 + github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.0 github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 github.com/hashicorp/go-secure-stdlib/password v0.1.1 @@ -58,6 +59,7 @@ require ( github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/go-jose/go-jose/v4 v4.0.1 // indirect + github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect diff --git a/sdk/go.sum b/sdk/go.sum index 55705f4931ac..b226fc928814 100644 --- a/sdk/go.sum +++ b/sdk/go.sum @@ -237,6 +237,8 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6 h1:kBoJV4Xl5FLtBfnBjDvBxeNSy2IRITSGs73HQsFUEjY= +github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6/go.mod h1:y+HSOcOGB48PkUxNyLAiCiY6rEENu+E+Ss4LG8QHwf4= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= @@ -255,6 +257,8 @@ github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5O github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng= github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= +github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.0 h1:4B46+S65WqQUlp0rX2F7TX6/p0HmUZsDD+cVzFTwztw= +github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.0/go.mod h1:hH8rgXHh9fPSDPerG6WzABHsHF+9ZpLhRI1LPk4JZ8c= github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 h1:p4AKXPPS24tO8Wc8i1gLvSKdmkiSY5xuju57czJ/IJQ= github.com/hashicorp/go-secure-stdlib/mlock v0.1.2/go.mod h1:zq93CJChV6L9QTfGKtfBxKqD7BqqXx5O04A/ns2p5+I= github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 h1:iBt4Ew4XEGLfh6/bPk4rSYmuZJGizr6/x/AEizP0CQc= diff --git a/sdk/helper/certutil/certutil_test.go b/sdk/helper/certutil/certutil_test.go index c872454f155d..5151b37cf6e8 100644 --- a/sdk/helper/certutil/certutil_test.go +++ b/sdk/helper/certutil/certutil_test.go @@ -26,6 +26,7 @@ import ( "time" "github.com/fatih/structs" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" ) // Tests converting back and forth between a CertBundle and a ParsedCertBundle. @@ -465,7 +466,7 @@ vitin0L6nprauWkKO38XgM4T75qKZpqtiOcT } func TestGetPublicKeySize(t *testing.T) { - rsa, err := rsa.GenerateKey(rand.Reader, 3072) + rsa, err := cryptoutil.GenerateRSAKey(rand.Reader, 3072) if err != nil { t.Fatal(err) } @@ -735,7 +736,7 @@ func setCerts() { // RSA generation { - key, err := rsa.GenerateKey(rand.Reader, 2048) + key, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) if err != nil { panic(err) } @@ -864,7 +865,7 @@ func setCerts() { func TestComparePublicKeysAndType(t *testing.T) { rsa1 := genRsaKey(t).Public() - rsa2 := genRsaKey(t).Public() + rsa := genRsaKey(t).Public() eddsa1 := genEdDSA(t).Public() eddsa2 := genEdDSA(t).Public() ed25519_1, _ := genEd25519Key(t) @@ -881,7 +882,7 @@ func TestComparePublicKeysAndType(t *testing.T) { wantErr bool }{ {name: "RSA_Equal", args: args{key1Iface: rsa1, key2Iface: rsa1}, want: true, wantErr: false}, - {name: "RSA_NotEqual", args: args{key1Iface: rsa1, key2Iface: rsa2}, want: false, wantErr: false}, + {name: "RSA_NotEqual", args: args{key1Iface: rsa1, key2Iface: rsa}, want: false, wantErr: false}, {name: "EDDSA_Equal", args: args{key1Iface: eddsa1, key2Iface: eddsa1}, want: true, wantErr: false}, {name: "EDDSA_NotEqual", args: args{key1Iface: eddsa1, key2Iface: eddsa2}, want: false, wantErr: false}, {name: "ED25519_Equal", args: args{key1Iface: ed25519_1, key2Iface: ed25519_1}, want: true, wantErr: false}, @@ -1106,7 +1107,7 @@ func TestIgnoreCSRSigning(t *testing.T) { } func genRsaKey(t *testing.T) *rsa.PrivateKey { - key, err := rsa.GenerateKey(rand.Reader, 2048) + key, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } diff --git a/sdk/helper/certutil/helpers.go b/sdk/helper/certutil/helpers.go index 1c673b058acd..8a03b7cedf7e 100644 --- a/sdk/helper/certutil/helpers.go +++ b/sdk/helper/certutil/helpers.go @@ -29,6 +29,8 @@ import ( "strings" "time" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" + "github.com/hashicorp/errwrap" "github.com/hashicorp/vault/sdk/helper/errutil" "github.com/hashicorp/vault/sdk/helper/jsonutil" @@ -368,7 +370,7 @@ func generatePrivateKey(keyType string, keyBits int, container ParsedPrivateKeyC return errutil.InternalError{Err: fmt.Sprintf("insecure bit length for RSA private key: %d", keyBits)} } privateKeyType = RSAPrivateKey - privateKey, err = rsa.GenerateKey(randReader, keyBits) + privateKey, err = cryptoutil.GenerateRSAKey(randReader, keyBits) if err != nil { return errutil.InternalError{Err: fmt.Sprintf("error generating RSA private key: %v", err)} } diff --git a/sdk/helper/certutil/types_test.go b/sdk/helper/certutil/types_test.go index 2cf383afaa02..02288d17d77a 100644 --- a/sdk/helper/certutil/types_test.go +++ b/sdk/helper/certutil/types_test.go @@ -9,12 +9,13 @@ import ( "crypto/ed25519" "crypto/elliptic" "crypto/rand" - "crypto/rsa" "testing" + + "github.com/hashicorp/vault/sdk/helper/cryptoutil" ) func TestGetPrivateKeyTypeFromPublicKey(t *testing.T) { - rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + rsaKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) if err != nil { t.Fatalf("error generating rsa key: %s", err) } diff --git a/sdk/helper/cryptoutil/rsa.go b/sdk/helper/cryptoutil/rsa.go new file mode 100644 index 000000000000..5c6790ba9c9a --- /dev/null +++ b/sdk/helper/cryptoutil/rsa.go @@ -0,0 +1,34 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package cryptoutil + +import ( + "crypto/rsa" + "io" + "os" + + "github.com/hashicorp/go-secure-stdlib/cryptoutil" + "github.com/hashicorp/vault/sdk/helper/parseutil" +) + +var disabled bool + +func init() { + s := os.Getenv("VAULT_DISABLE_RSA_DRBG") + var err error + disabled, err = parseutil.ParseBool(s) + if err != nil { + // Assume it's a typo and disable + disabled = true + } +} + +// Uses go-secure-stdlib's GenerateRSAKey routine conditionally. This exists to be able to disable the feature +// via an ENV var in a pinch +func GenerateRSAKey(randomSource io.Reader, bits int) (*rsa.PrivateKey, error) { + if disabled { + return rsa.GenerateKey(randomSource, bits) + } + return cryptoutil.GenerateRSAKey(randomSource, bits) +} diff --git a/sdk/helper/keysutil/policy.go b/sdk/helper/keysutil/policy.go index 138918eb8cb3..3e4be8b1cd71 100644 --- a/sdk/helper/keysutil/policy.go +++ b/sdk/helper/keysutil/policy.go @@ -35,6 +35,7 @@ import ( "github.com/hashicorp/errwrap" "github.com/hashicorp/go-uuid" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" "github.com/hashicorp/vault/sdk/helper/errutil" "github.com/hashicorp/vault/sdk/helper/jsonutil" "github.com/hashicorp/vault/sdk/helper/kdf" @@ -1825,7 +1826,7 @@ func (p *Policy) RotateInMemory(randReader io.Reader) (retErr error) { bitSize = 4096 } - entry.RSAKey, err = rsa.GenerateKey(randReader, bitSize) + entry.RSAKey, err = cryptoutil.GenerateRSAKey(randReader, bitSize) if err != nil { return err } diff --git a/sdk/helper/keysutil/policy_test.go b/sdk/helper/keysutil/policy_test.go index 5e3ce1ee9d99..dd125e0b88c1 100644 --- a/sdk/helper/keysutil/policy_test.go +++ b/sdk/helper/keysutil/policy_test.go @@ -22,6 +22,7 @@ import ( "testing" "time" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" "github.com/hashicorp/vault/sdk/helper/errutil" "github.com/hashicorp/vault/sdk/helper/jsonutil" "github.com/hashicorp/vault/sdk/logical" @@ -810,7 +811,7 @@ func Test_Import(t *testing.T) { func generateTestKeys() (map[KeyType][]byte, error) { keyMap := make(map[KeyType][]byte) - rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + rsaKey, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048) if err != nil { return nil, err } @@ -820,7 +821,7 @@ func generateTestKeys() (map[KeyType][]byte, error) { } keyMap[KeyType_RSA2048] = rsaKeyBytes - rsaKey, err = rsa.GenerateKey(rand.Reader, 3072) + rsaKey, err = cryptoutil.GenerateRSAKey(rand.Reader, 3072) if err != nil { return nil, err } @@ -830,7 +831,7 @@ func generateTestKeys() (map[KeyType][]byte, error) { } keyMap[KeyType_RSA3072] = rsaKeyBytes - rsaKey, err = rsa.GenerateKey(rand.Reader, 4096) + rsaKey, err = cryptoutil.GenerateRSAKey(rand.Reader, 4096) if err != nil { return nil, err } diff --git a/vault/identity_store_oidc.go b/vault/identity_store_oidc.go index 5b34631f2d7d..a5c487456b2e 100644 --- a/vault/identity_store_oidc.go +++ b/vault/identity_store_oidc.go @@ -8,7 +8,6 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" - "crypto/rsa" "encoding/base64" "encoding/json" "errors" @@ -30,6 +29,7 @@ import ( "github.com/hashicorp/vault/helper/namespace" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/helper/consts" + "github.com/hashicorp/vault/sdk/helper/cryptoutil" "github.com/hashicorp/vault/sdk/helper/identitytpl" "github.com/hashicorp/vault/sdk/logical" "github.com/patrickmn/go-cache" @@ -1762,7 +1762,7 @@ func generateKeys(algorithm string) (*jose.JSONWebKey, error) { switch algorithm { case "RS256", "RS384", "RS512": // 2048 bits is recommended by RSA Laboratories as a minimum post 2015 - if key, err = rsa.GenerateKey(rand.Reader, 2048); err != nil { + if key, err = cryptoutil.GenerateRSAKey(rand.Reader, 2048); err != nil { return nil, err } case "ES256", "ES384", "ES512": From c5488715b57d7674b16b56ab735cdd031aae353e Mon Sep 17 00:00:00 2001 From: Violet Hynes Date: Thu, 5 Dec 2024 17:15:46 -0500 Subject: [PATCH 25/45] Fix missing locking in mount tests (#29110) --- vault/mount_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/vault/mount_test.go b/vault/mount_test.go index 1bf10cc05d3c..f2d87cb7bfce 100644 --- a/vault/mount_test.go +++ b/vault/mount_test.go @@ -144,6 +144,8 @@ func TestCore_DefaultMountTable(t *testing.T) { c.mountsLock.Lock() defer c.mountsLock.Unlock() + c2.mountsLock.Lock() + defer c2.mountsLock.Unlock() if diff := deep.Equal(c.mounts.sortEntriesByPath(), c2.mounts.sortEntriesByPath()); len(diff) > 0 { t.Fatalf("mismatch: %v", diff) } @@ -192,6 +194,8 @@ func TestCore_Mount(t *testing.T) { // Verify matching mount tables c.mountsLock.Lock() defer c.mountsLock.Unlock() + c2.mountsLock.Lock() + defer c2.mountsLock.Unlock() if diff := deep.Equal(c.mounts.sortEntriesByPath(), c2.mounts.sortEntriesByPath()); len(diff) > 0 { t.Fatalf("mismatch: %v", diff) } @@ -266,6 +270,8 @@ func TestCore_Mount_kv_generic(t *testing.T) { // Verify matching mount tables c.mountsLock.Lock() defer c.mountsLock.Unlock() + c2.mountsLock.Lock() + defer c2.mountsLock.Unlock() if diff := deep.Equal(c.mounts.sortEntriesByPath(), c2.mounts.sortEntriesByPath()); len(diff) > 0 { t.Fatalf("mismatch: %v", diff) } From 2c78ab99fa0a8a1dc6e0bc652f4e80659cf1c5a6 Mon Sep 17 00:00:00 2001 From: Yoko Hyakuna Date: Thu, 5 Dec 2024 14:42:55 -0800 Subject: [PATCH 26/45] [DOCS] Update the doc title and description for SEO improvements (#29100) * Update the doc title and description for SEO improvements * Update the side-nav name --- website/content/docs/sync/awssm.mdx | 7 ++++--- website/content/docs/sync/azurekv.mdx | 7 ++++--- website/content/docs/sync/gcpsm.mdx | 7 ++++--- website/content/docs/sync/github.mdx | 9 ++++++--- website/content/docs/sync/index.mdx | 4 ++-- website/content/docs/sync/vercelproject.mdx | 7 ++++--- website/data/docs-nav-data.json | 13 ++++++------- 7 files changed, 30 insertions(+), 24 deletions(-) diff --git a/website/content/docs/sync/awssm.mdx b/website/content/docs/sync/awssm.mdx index 3bae604e591f..c32873a36ede 100644 --- a/website/content/docs/sync/awssm.mdx +++ b/website/content/docs/sync/awssm.mdx @@ -1,10 +1,11 @@ --- layout: docs -page_title: AWS Secrets Manager - Secrets Sync Destination -description: The AWS Secrets Manager destination syncs secrets from Vault to AWS. +page_title: Sync secrets from Vault to AWS Secrets Manager +description: >- + Automatically sync and unsync the secrets from Vault to AWS Secrets Manager to centralize visibility and control of secrets lifecycle management. --- -# AWS Secrets Manager +# Sync secrets from Vault to AWS Secrets Manager The AWS Secrets Manager destination enables Vault to sync and unsync secrets of your choosing into an external AWS account. When configured, Vault will actively maintain the state of each externally-synced diff --git a/website/content/docs/sync/azurekv.mdx b/website/content/docs/sync/azurekv.mdx index 943700b01c8f..affc3f1e80c1 100644 --- a/website/content/docs/sync/azurekv.mdx +++ b/website/content/docs/sync/azurekv.mdx @@ -1,10 +1,11 @@ --- layout: docs -page_title: Azure Key Vault - Secrets Sync Destination -description: The Azure Key Vault destination syncs secrets from Vault to Azure. +page_title: Sync secrets from Vault to Azure Key Vault +description: >- + Automatically sync and unsync the secrets from Vault to Azure Key Vault to centralize visibility and control of secrets lifecycle management. --- -# Azure Key Vault +# Sync secrets from Vault to Azure Key Vault The Azure Key Vault destination enables Vault to sync and unsync secrets of your choosing into an external Azure account. When configured, Vault will actively maintain the state of each externally-synced diff --git a/website/content/docs/sync/gcpsm.mdx b/website/content/docs/sync/gcpsm.mdx index e5df04026c8b..f153194f026b 100644 --- a/website/content/docs/sync/gcpsm.mdx +++ b/website/content/docs/sync/gcpsm.mdx @@ -1,10 +1,11 @@ --- layout: docs -page_title: Google Cloud Platform Secret Manager - Secrets Sync Destination -description: The Google Cloud Platform Secret Manager destination syncs secrets from Vault to GCP. +page_title: Sync secrets from Vault to GCP Secret Manager +description: >- + Automatically sync and unsync the secrets from Vault to GCP Secret Manager to centralize visibility and control of secrets lifecycle management. --- -# Google Cloud Platform Secret Manager +# Sync secrets from Vault to GCP Secret Manager The Google Cloud Platform (GCP) Secret Manager sync destination allows Vault to safely synchronize secrets to your GCP projects. This is a low footprint option that enables your applications to benefit from Vault-managed secrets without requiring them diff --git a/website/content/docs/sync/github.mdx b/website/content/docs/sync/github.mdx index 174dbf6e5d89..29aa9610e505 100644 --- a/website/content/docs/sync/github.mdx +++ b/website/content/docs/sync/github.mdx @@ -1,10 +1,11 @@ --- layout: docs -page_title: GitHub - Secrets Sync Destination -description: The GitHub destination syncs secrets from Vault to GitHub. +page_title: Sync secrets from Vault to GitHub +description: >- + Automatically sync and unsync the secrets from Vault to GitHub to centralize visibility and control of secrets lifecycle management. --- -# GitHub actions secrets +# Sync secrets from Vault to GitHub The GitHub actions sync destination allows Vault to safely synchronize secrets as GitHub organization, repository, or environment secrets. This is a low footprint option that enables your applications to benefit from Vault-managed secrets without requiring them @@ -23,8 +24,10 @@ Prerequisites: + Access tokens are tied to a user account and can be revoked at any time, causing disruptions to the sync process. GitHub applications are long-lived and do not expire. Using a GitHub application for authentication is preferred over using a personal access token. + ### Repositories diff --git a/website/content/docs/sync/index.mdx b/website/content/docs/sync/index.mdx index dd53d8631528..1b57ab9ac296 100644 --- a/website/content/docs/sync/index.mdx +++ b/website/content/docs/sync/index.mdx @@ -1,10 +1,10 @@ --- layout: docs page_title: Secrets sync -description: Secrets sync allows you to safely sync Vault-managed secrets with external destinations. +description: >- + Use secrets sync feature to automatically sync Vault-managed secrets with external destinations to centralize secrets lifecycle management. --- - # Secrets sync diff --git a/website/content/docs/sync/vercelproject.mdx b/website/content/docs/sync/vercelproject.mdx index 0e22eb78d2db..8ec3121f7589 100644 --- a/website/content/docs/sync/vercelproject.mdx +++ b/website/content/docs/sync/vercelproject.mdx @@ -1,10 +1,11 @@ --- layout: docs -page_title: Vercel Project - Secrets Sync Destination -description: The Vercel Project destination syncs secrets from Vault to Vercel. +page_title: Sync secrets from Vault to Vercel Project +description: >- + Automatically sync and unsync the secrets from Vault to a Vercel project to centralize visibility and control of secrets lifecycle management. --- -# Vercel Project environment variables +# Sync secrets from Vault to Vercel Project The Vercel Project sync destination allows Vault to safely synchronize secrets as Vercel environment variables. This is a low footprint option that enables your applications to benefit from Vault-managed secrets without requiring them diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index 4d95e37961ad..fb4f85704a34 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -1581,8 +1581,7 @@ }, { "title": "KV version 2", - "routes": - [ + "routes": [ { "title": "Overview", "path": "secrets/kv/kv-v2" @@ -1823,23 +1822,23 @@ "path": "sync" }, { - "title": "AWS Secrets Manager", + "title": "Sync to AWS Secrets Manager", "path": "sync/awssm" }, { - "title": "Azure Key Vault", + "title": "Sync to Azure Key Vault", "path": "sync/azurekv" }, { - "title": "GCP Secret Manager", + "title": "Sync to GCP Secret Manager", "path": "sync/gcpsm" }, { - "title": "GitHub", + "title": "Sync to GitHub", "path": "sync/github" }, { - "title": "Vercel Project", + "title": "Sync to Vercel Project", "path": "sync/vercelproject" } ] From 0810b583c41f0f549ff7bd4d134d32af989912bc Mon Sep 17 00:00:00 2001 From: Brian Shumate Date: Fri, 6 Dec 2024 12:22:45 -0500 Subject: [PATCH 27/45] Docs: add production hardening document (#29019) - Add production hardening document to concepts from tutorial - Update content for linting and grammar --- .../docs/concepts/production-hardening.mdx | 215 ++++++++++++++++++ website/data/docs-nav-data.json | 4 + 2 files changed, 219 insertions(+) create mode 100644 website/content/docs/concepts/production-hardening.mdx diff --git a/website/content/docs/concepts/production-hardening.mdx b/website/content/docs/concepts/production-hardening.mdx new file mode 100644 index 000000000000..532bd2b7200d --- /dev/null +++ b/website/content/docs/concepts/production-hardening.mdx @@ -0,0 +1,215 @@ +--- +layout: docs +page_title: Production hardening +description: >- + Harden your Vault deployments for production operations. +--- + +# Production hardening + +You can use the best practices in this document to harden Vault when planning +your production deployment. These recommendations follow the +[Vault security model](/vault/docs/internals/security), and focus on defense +in depth. + +You should follow the **baseline recommendations** if at all possible for any +production Vault deployment. The **extended recommendations** detail extra +layers of security which may require more administrative overhead, and might +not be suitable for every deployment. + +## Baseline recommendations + +- **Do not run as root**. Use a dedicated, unprivileged service account to run + Vault, rather than running as the root or Administrator account. Vault is + designed to run as an unprivileged user, and doing so adds significant + defense against various privilege-escalation attacks. + +- **Allow minimal write privileges**. The unprivileged Vault service account + should not have access to overwrite its executable binary or any Vault + configuration files. Limit what is writable by the Vault user to just + directories and files for local Vault storage (for example, Integrated + Storage) or file audit device logs. + +- **Use end-to-end TLS**. You should always use Vault with TLS in production. + If you use intermediate load balancers or reverse proxies to front Vault, + you should enable TLS for all network connections between every part of the + system (including external storage) to ensure encryption of all traffic in + transit to and from Vault. When possible, you should set the HTTP Strict + Transport Security (HSTS) header using Vault's [custom response headers](/vault/docs/configuration/listener/tcp#configuring-custom-http-response-headers) feature. + +- **Disable swap**. Vault encrypts data in transit and at rest, however it must + still have sensitive data in memory to function. Risk of exposure should be + minimized by disabling swap to prevent the operating system from paging + sensitive data to disk. Disabling swap is even more critical when your + Vault deployment uses Integrated Storage. + +- **Disable core dumps**. A user or administrator that can force a core dump + and has access to the resulting file can potentially access Vault encryption + keys. Preventing core dumps is a platform-specific process; on Linux setting + the resource limit `RLIMIT_CORE` to `0` disables core dumps. In the systemd + service unit file, setting `LimitCORE=0` will enforce this setting for the + Vault service. + +- **Use single tenancy**. Vault should be the sole user process running on a + machine. This reduces the risk that another process running on the same + machine gets compromised and gains the ability to interact with the Vault + process. Similarly, you should prefer running Vault on bare metal instead + of a virtual machine, and you prefer running in a virtual machine instead + of running in a containerized environment. + +- **Firewall traffic**. Use a local firewall or network security features of + your cloud provider to restrict incoming and outgoing traffic to Vault and + essential system services like NTP. This includes restricting incoming + traffic to permitted sub-networks and outgoing traffic to services Vault + needs to connect to, such as databases. + +- **Avoid root tokens**. When you initialize Vault, it emits an initial + root token. You should use this token just to perform initial setup, + such as enabling auth methods so that users can authenticate. You should + treat Vault [configuration as + code](https://www.hashicorp.com/blog/codifying-vault-policies-and-configuration/), + and use version control to manage policies. Once you complete initial Vault + setup, you should revoke the initial root token to reduce risk of exposure. Root tokens can be + [generated when needed](/vault/docs/commands/operator/generate-root), and should be + revoked when no longer needed. + +- **Configure user lockout**. Vault provides a [user lockout](/vault/docs/concepts/user-lockout) function + for the [approle](/vault/docs/auth/approle), [ldap](/vault/docs/auth/ldap) and [userpass](/vault/docs/auth/userpass) + auth methods. **Vault enables user lockout by default**. Verify the lockout threshold, and lockout duration matches your organizations security policies. + +- **Enable audit device logs**. Vault supports several [audit + devices](/vault/docs/audit). When you enable audit device logs, you gain + a detailed history of all operations performed by Vault, and a forensics + trail in the case of misuse or compromise. Audit logs [securely + hash](/vault/docs/audit#sensitive-information) + sensitive data, but you should still restrict access to prevent any + unintended information disclosure. + +- **Disable shell command history**. You may want the `vault` command itself to + not appear in history at all. + +- **Keep a frequent upgrade cadence**. Vault is actively developed, and you + should upgrade Vault often to incorporate security fixes and any changes in + default settings such as key lengths or cipher suites. Subscribe to the + [HashiCorp Announcement mailing list](https://groups.google.com/g/hashicorp-announce) + to receive announcements of new releases and visit the [Vault + CHANGELOG](https://github.com/hashicorp/vault/blob/main/CHANGELOG.md) for + details on the changes made in each release. + +- **Synchronize clocks**. Use NTP or whatever mechanism is appropriate for your + environment to ensure that all the Vault nodes agree about what time it is. + Vault uses the clock for things like enforcing TTLs and setting dates in PKI + certificates, and if the nodes have significant clock skew, a failover can wreak havoc. + +- **Restrict storage access**. Vault encrypts all data at rest, regardless of + which storage type it uses. Although Vault encrypts the data, an [attacker + with arbitrary + control](/vault/docs/internals/security) can cause + data corruption or loss by modifying or deleting keys. You should restrict + storage access outside of Vault to avoid unauthorized access or operations. + +- **Do not use clear text credentials**. The Vault configuration [`seal` + stanza](/vault/docs/configuration/seal) configures the seal type to use for + extra data protection such as using HSM or Cloud KMS solutions to encrypt and + decrypt the root key. **DO NOT** store your cloud credentials or HSM pin in + clear text within the `seal` stanza. If you host the Vault server on the same + cloud platform as the KMS service, use the platform-specific identity + solutions. For example: + + - [Resource Access Management (RAM) on AliCloud](/vault/docs/configuration/seal/alicloudkms#authentication) + - [Instance Profiles on AWS](/vault/docs/configuration/seal/awskms#authentication) + - [Managed Service Identities (MSI) on Azure](/vault/docs/configuration/seal/azurekeyvault#authentication) + - [Service Account on Google Cloud Platform](/vault/docs/configuration/seal/gcpckms#authentication-permissions) + + When using platform-specific identity solutions, you should be mindful of auth + method and secret engine configuration within namespaces. You can share + platform identity across Vault namespaces, as these provider features + generally offer host-based identity solutions. + + If that is not applicable, set the credentials as environment variables + (for example, `VAULT_HSM_PIN`). + +- **Use the safest algorithms available**. [Vault's TLS listener](/vault/docs/configuration/listener/tcp#tls_cipher_suites) + supports a variety of legacy algorithms for backwards compatibility. While + these algorithms are available, they are not recommended for use when + a stronger alternative is available. If possible, use TLS 1.3 to ensure + that modern encryption algorithms encrypt data in transit and offer + forward secrecy. + +- **Follow best practices for plugins**. While HashiCorp-developed plugins + generally default to a safe configuration, you should be mindful of + misconfigured or malicious Vault plugins. These plugin issues can harm the + security posture of your Vault deployment. + +- **Be aware of non-deterministic configuration file merging**. Vault's + configuration file merging is non-deterministic, and inconsistencies in + settings between files can lead to inconsistencies in Vault settings. + Ensure set configurations are consistent across all files (and any files merged together get denoted by a `-config` flag). + +- **Use correct filesystem permissions**. Always ensure appropriate permissions + get applied to files before starting Vault. This is even more critical for files which contain sensitive information. + +- **Use standard input for vault secrets**. [Vault login](/vault/docs/commands/login) + and [Vault unseal](/vault/api-docs/system/unseal#key) allow operators to + give secret values from either standard input or with command-line arguments. + Command-line arguments can persisted in shell history, and are readable by other unprivileged users on the same host. + +- **Develop an off-boarding process**. Removing accounts in Vault or associated + identity providers may not immediately revoke [token-based access](/vault/docs/concepts/tokens#user-management-considerations). + Depending on how you manage access to Vault, operators should consider: + + - Removing the entity from groups granting access to resources. + - [Revoking](/vault/docs/concepts/lease#prefix-based-revocation) the active leases for a given user account. + - Deleting the canonical entity of the user after removing accounts in Vault or associated identity providers. + Deleting the canonical entity alone is insufficient as one is automatically created on successful login if it does not exist. + - [Disabling](/vault/docs/commands/auth/disable) auth methods instead of deleting them, which revokes all + tokens generated by this auth method. + +- **Use short TTLs** When possible, credentials issued from Vault (for example + tokens, X.509 certificates) should be short-lived, as to guard against their potential compromise, and reduce the need to use revocation methods. + +## Extended recommendations + +- **Disable SSH / remote desktop**. When running a Vault as a single tenant + application, users should never access the machine directly. Instead, they + should access Vault through its API over the network. Use a centralized + logging and telemetry solution for debugging. Be sure to restrict access to + logs as need to know. + +- **Use systemd security features**. Systemd provides a number of features + that you can use to lock down access to the filesystem and to + administrative capabilities. The service unit file provided with the + official Vault Linux packages sets a number of these by default, including: + + ```plaintext + ProtectSystem=full + PrivateTmp=yes + CapabilityBoundingSet=CAP_SYSLOG CAP_IPC_LOCK + AmbientCapabilities=CAP_IPC_LOCK + ProtectHome=read-only + PrivateDevices=yes + NoNewPrivileges=yes + ``` + + See the [systemd.exec manual page](https://www.freedesktop.org/software/systemd/man/systemd.exec.html) for more details. + +- **Perform immutable upgrades**. Vault relies on external storage for + persistence, and this decoupling allows the servers running Vault to be + immutably managed. When you upgrade to a new version, you can bring new + servers with the upgraded version of Vault online. You can attach the new + servers to the same shared storage and unseal them. Then you can destroy the + older version servers. This reduces the need for remote access and upgrade orchestration which may introduce security gaps. + +- **Configure SELinux / AppArmor**. Using mechanisms like + [SELinux](https://github.com/hashicorp/vault-selinux-policies) + and AppArmor can help you gain layers of security when using Vault. + While Vault can run on several popular operating systems, Linux is + recommended due to the various security primitives mentioned here. + +- **Adjust user limits**. It is possible that your Linux distribution enforces + strict process user limits (`ulimits`). Consider a review of `ulimits` for maximum amount of open files, connections, etc. before going into production. You might need to increase the default values to avoid errors about too + many open files. + +- **Be aware of special container considerations**. To use memory locking + (mlock) inside a Vault container, you need to use the `overlayfs2` or another + supporting driver. diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index fb4f85704a34..9f5dfb84af7b 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -233,6 +233,10 @@ "title": "High Availability", "path": "concepts/ha" }, + { + "title": "Production hardening", + "path": "concepts/production-hardening" + }, { "title": "Storage", "path": "concepts/storage" From 7d26c54350127cb541ad70a0aa631d251affde96 Mon Sep 17 00:00:00 2001 From: Steven Clark Date: Mon, 9 Dec 2024 08:03:16 -0500 Subject: [PATCH 28/45] Do not use static certificates for diagnose tests (#29122) * Do not use static certificates for diagnose tests * Fix operator command tests, move PKI CA creation code into testhelper lib * Fix compilation error from refactoring --- command/operator_diagnose_test.go | 53 ++++- .../diagnose_seal_transit_tls_check.hcl | 6 +- .../server/test-fixtures/tls_config_ok.hcl | 4 +- helper/testhelpers/pki/pkihelper.go | 224 ++++++++++++++++++ .../test-fixtures/goodcertwithroot.pem | 42 ---- vault/diagnose/tls_verification_test.go | 73 ++++-- 6 files changed, 331 insertions(+), 71 deletions(-) create mode 100644 helper/testhelpers/pki/pkihelper.go delete mode 100644 vault/diagnose/test-fixtures/goodcertwithroot.pem diff --git a/command/operator_diagnose_test.go b/command/operator_diagnose_test.go index 8528637dc2e4..ddbcc204689a 100644 --- a/command/operator_diagnose_test.go +++ b/command/operator_diagnose_test.go @@ -10,11 +10,13 @@ import ( "fmt" "io/ioutil" "os" + "path/filepath" "strings" "testing" "github.com/hashicorp/cli" "github.com/hashicorp/vault/helper/constants" + pkihelper "github.com/hashicorp/vault/helper/testhelpers/pki" "github.com/hashicorp/vault/vault/diagnose" ) @@ -31,8 +33,55 @@ func testOperatorDiagnoseCommand(tb testing.TB) *OperatorDiagnoseCommand { } } +func generateTLSConfigOk(t *testing.T, ca pkihelper.LeafWithIntermediary) string { + t.Helper() + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "tls_config_ok.hcl") + + templateFile := "./server/test-fixtures/tls_config_ok.hcl" + contents, err := os.ReadFile(templateFile) + if err != nil { + t.Fatalf("failed to read file %s: %v", templateFile, err) + } + contents = []byte(strings.ReplaceAll(string(contents), "{REPLACE_LEAF_CERT_FILE}", ca.Leaf.CertFile)) + contents = []byte(strings.ReplaceAll(string(contents), "{REPLACE_LEAF_KEY_FILE}", ca.Leaf.KeyFile)) + + err = os.WriteFile(configPath, contents, 0o644) + if err != nil { + t.Fatalf("failed to write file %s: %v", configPath, err) + } + + return configPath +} + +func generateTransitTLSCheck(t *testing.T, ca pkihelper.LeafWithIntermediary) string { + t.Helper() + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "diagnose_seal_transit_tls_check.hcl") + + templateFile := "./server/test-fixtures/diagnose_seal_transit_tls_check.hcl" + contents, err := os.ReadFile(templateFile) + if err != nil { + t.Fatalf("failed to read file %s: %v", templateFile, err) + } + contents = []byte(strings.ReplaceAll(string(contents), "{REPLACE_LEAF_CERT_FILE}", ca.Leaf.CertFile)) + contents = []byte(strings.ReplaceAll(string(contents), "{REPLACE_LEAF_KEY_FILE}", ca.Leaf.KeyFile)) + contents = []byte(strings.ReplaceAll(string(contents), "{REPLACE_COMBINED_CA_CHAIN_FILE}", ca.CombinedCaFile)) + + err = os.WriteFile(configPath, contents, 0o644) + if err != nil { + t.Fatalf("failed to write file %s: %v", configPath, err) + } + + return configPath +} + func TestOperatorDiagnoseCommand_Run(t *testing.T) { t.Parallel() + testca := pkihelper.GenerateCertWithIntermediaryRoot(t) + tlsConfigOkConfigFile := generateTLSConfigOk(t, testca) + transitTLSCheckConfigFile := generateTransitTLSCheck(t, testca) + cases := []struct { name string args []string @@ -349,7 +398,7 @@ func TestOperatorDiagnoseCommand_Run(t *testing.T) { { "diagnose_listener_config_ok", []string{ - "-config", "./server/test-fixtures/tls_config_ok.hcl", + "-config", tlsConfigOkConfigFile, }, []*diagnose.Result{ { @@ -461,7 +510,7 @@ func TestOperatorDiagnoseCommand_Run(t *testing.T) { { "diagnose_seal_transit_tls_check_fail", []string{ - "-config", "./server/test-fixtures/diagnose_seal_transit_tls_check.hcl", + "-config", transitTLSCheckConfigFile, }, []*diagnose.Result{ { diff --git a/command/server/test-fixtures/diagnose_seal_transit_tls_check.hcl b/command/server/test-fixtures/diagnose_seal_transit_tls_check.hcl index a7007d57313a..de632c152acc 100644 --- a/command/server/test-fixtures/diagnose_seal_transit_tls_check.hcl +++ b/command/server/test-fixtures/diagnose_seal_transit_tls_check.hcl @@ -20,9 +20,9 @@ backend "consul" { seal "transit" { // TLS Configuration - tls_ca_cert = "./../vault/diagnose/test-fixtures/chain.crt.pem" - tls_client_cert = "./../vault/diagnose/test-fixtures/goodcertwithroot.pem" - tls_client_key = "./../vault/diagnose//test-fixtures/goodkey.pem" + tls_ca_cert = "{REPLACE_COMBINED_CA_CHAIN_FILE}" + tls_client_cert = "{REPLACE_LEAF_CERT_FILE}" + tls_client_key = "{REPLACE_LEAF_KEY_FILE}" tls_server_name = "vault" tls_skip_verify = "false" } diff --git a/command/server/test-fixtures/tls_config_ok.hcl b/command/server/test-fixtures/tls_config_ok.hcl index 02a2733d4138..7babfff9ae8f 100644 --- a/command/server/test-fixtures/tls_config_ok.hcl +++ b/command/server/test-fixtures/tls_config_ok.hcl @@ -8,8 +8,8 @@ ui = true listener "tcp" { address = "127.0.0.1:1025" - tls_cert_file = "./../api/test-fixtures/keys/cert.pem" - tls_key_file = "./../api/test-fixtures/keys/key.pem" + tls_cert_file = "{REPLACE_LEAF_CERT_FILE}" + tls_key_file = "{REPLACE_LEAF_KEY_FILE}" } backend "consul" { diff --git a/helper/testhelpers/pki/pkihelper.go b/helper/testhelpers/pki/pkihelper.go new file mode 100644 index 000000000000..e76d4e54229b --- /dev/null +++ b/helper/testhelpers/pki/pkihelper.go @@ -0,0 +1,224 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package pki + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + mathrand2 "math/rand/v2" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +// This file contains helper functions for generating CA hierarchies for testing + +type LeafWithRoot struct { + RootCa GeneratedCert + Leaf GeneratedCert + CombinedLeafCaFile string +} + +type LeafWithIntermediary struct { + RootCa GeneratedCert + IntCa GeneratedCert + Leaf GeneratedCert + CombinedCaFile string +} + +type GeneratedCert struct { + KeyFile string + CertFile string + CertPem *pem.Block + Cert *x509.Certificate + Key *ecdsa.PrivateKey +} + +// GenerateCertWithIntermediaryRoot generates a leaf certificate signed by an intermediary root CA +func GenerateCertWithIntermediaryRoot(t testing.TB) LeafWithIntermediary { + t.Helper() + tempDir := t.TempDir() + template := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "localhost", + }, + SerialNumber: big.NewInt(mathrand2.Int64()), + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + NotBefore: time.Now().Add(-30 * time.Second), + NotAfter: time.Now().Add(60 * 24 * time.Hour), + } + + ca := GenerateRootCa(t) + caIntTemplate := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Intermediary CA", + }, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + SerialNumber: big.NewInt(mathrand2.Int64()), + NotBefore: time.Now().Add(-30 * time.Second), + NotAfter: time.Now().Add(262980 * time.Hour), + BasicConstraintsValid: true, + IsCA: true, + } + caInt := generateCertAndSign(t, caIntTemplate, ca, tempDir, "int_") + leafCert := generateCertAndSign(t, template, caInt, tempDir, "leaf_") + + combinedCasFile := filepath.Join(tempDir, "cas.pem") + err := os.WriteFile(combinedCasFile, append(pem.EncodeToMemory(caInt.CertPem), pem.EncodeToMemory(ca.CertPem)...), 0o644) + if err != nil { + t.Fatal(err) + } + + return LeafWithIntermediary{ + RootCa: ca, + IntCa: caInt, + Leaf: leafCert, + CombinedCaFile: combinedCasFile, + } +} + +// generateCertAndSign generates a certificate and associated key signed by a CA +func generateCertAndSign(t testing.TB, template *x509.Certificate, ca GeneratedCert, tempDir string, filePrefix string) GeneratedCert { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + certBytes, err := x509.CreateCertificate(rand.Reader, template, ca.Cert, key.Public(), ca.Key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(certBytes) + if err != nil { + t.Fatal(err) + } + certPEMBlock := &pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + } + certFile := filepath.Join(tempDir, filePrefix+"cert.pem") + err = os.WriteFile(certFile, pem.EncodeToMemory(certPEMBlock), 0o644) + if err != nil { + t.Fatal(err) + } + marshaledKey, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + keyPEMBlock := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: marshaledKey, + } + keyFile := filepath.Join(tempDir, filePrefix+"key.pem") + err = os.WriteFile(keyFile, pem.EncodeToMemory(keyPEMBlock), 0o644) + if err != nil { + t.Fatal(err) + } + return GeneratedCert{ + KeyFile: keyFile, + CertFile: certFile, + CertPem: certPEMBlock, + Cert: cert, + Key: key, + } +} + +// GenerateCertWithRoot generates a leaf certificate signed by a root CA +func GenerateCertWithRoot(t testing.TB) LeafWithRoot { + t.Helper() + tempDir := t.TempDir() + leafTemplate := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "localhost", + }, + SerialNumber: big.NewInt(mathrand2.Int64()), + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + NotBefore: time.Now().Add(-30 * time.Second), + NotAfter: time.Now().Add(60 * 24 * time.Hour), + } + + ca := GenerateRootCa(t) + leafCert := generateCertAndSign(t, leafTemplate, ca, tempDir, "leaf_") + + combinedCaLeafFile := filepath.Join(tempDir, "leaf-ca.pem") + err := os.WriteFile(combinedCaLeafFile, append(pem.EncodeToMemory(leafCert.CertPem), pem.EncodeToMemory(ca.CertPem)...), 0o644) + if err != nil { + t.Fatal(err) + } + + return LeafWithRoot{ + RootCa: ca, + Leaf: leafCert, + CombinedLeafCaFile: combinedCaLeafFile, + } +} + +// GenerateRootCa generates a self-signed root CA certificate and key +func GenerateRootCa(t testing.TB) GeneratedCert { + t.Helper() + tempDir := t.TempDir() + + caCertTemplate := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Root CA", + }, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + SerialNumber: big.NewInt(mathrand2.Int64()), + NotBefore: time.Now().Add(-30 * time.Second), + NotAfter: time.Now().Add(262980 * time.Hour), + BasicConstraintsValid: true, + IsCA: true, + } + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + caBytes, err := x509.CreateCertificate(rand.Reader, caCertTemplate, caCertTemplate, caKey.Public(), caKey) + if err != nil { + t.Fatal(err) + } + caCert, err := x509.ParseCertificate(caBytes) + if err != nil { + t.Fatal(err) + } + caCertPEMBlock := &pem.Block{ + Type: "CERTIFICATE", + Bytes: caBytes, + } + caFile := filepath.Join(tempDir, "ca_root_cert.pem") + err = os.WriteFile(caFile, pem.EncodeToMemory(caCertPEMBlock), 0o644) + if err != nil { + t.Fatal(err) + } + marshaledCAKey, err := x509.MarshalECPrivateKey(caKey) + if err != nil { + t.Fatal(err) + } + caKeyPEMBlock := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: marshaledCAKey, + } + caKeyFile := filepath.Join(tempDir, "ca_root_key.pem") + err = os.WriteFile(caKeyFile, pem.EncodeToMemory(caKeyPEMBlock), 0o644) + if err != nil { + t.Fatal(err) + } + return GeneratedCert{ + CertPem: caCertPEMBlock, + CertFile: caFile, + KeyFile: caKeyFile, + Cert: caCert, + Key: caKey, + } +} diff --git a/vault/diagnose/test-fixtures/goodcertwithroot.pem b/vault/diagnose/test-fixtures/goodcertwithroot.pem deleted file mode 100644 index 6e4baf613a39..000000000000 --- a/vault/diagnose/test-fixtures/goodcertwithroot.pem +++ /dev/null @@ -1,42 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDtTCCAp2gAwIBAgIUf+jhKTFBnqSs34II0WS1L4QsbbAwDQYJKoZIhvcNAQEL -BQAwFjEUMBIGA1UEAxMLZXhhbXBsZS5jb20wHhcNMTYwMjI5MDIyNzQxWhcNMjUw -MTA1MTAyODExWjAbMRkwFwYDVQQDExBjZXJ0LmV4YW1wbGUuY29tMIIBIjANBgkq -hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsZx0Svr82YJpFpIy4fJNW5fKA6B8mhxS -TRAVnygAftetT8puHflY0ss7Y6X2OXjsU0PRn+1PswtivhKi+eLtgWkUF9cFYFGn -SgMld6ZWRhNheZhA6ZfQmeM/BF2pa5HK2SDF36ljgjL9T+nWrru2Uv0BCoHzLAmi -YYMiIWplidMmMO5NTRG3k+3AN0TkfakB6JVzjLGhTcXdOcVEMXkeQVqJMAuGouU5 -donyqtnaHuIJGuUdy54YDnX86txhOQhAv6r7dHXzZxS4pmLvw8UI1rsSf/GLcUVG -B+5+AAGF5iuHC3N2DTl4xz3FcN4Cb4w9pbaQ7+mCzz+anqiJfyr2nwIDAQABo4H1 -MIHyMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAdBgNVHQ4EFgQUm++e -HpyM3p708bgZJuRYEdX1o+UwHwYDVR0jBBgwFoAUncSzT/6HMexyuiU9/7EgHu+o -k5swOwYIKwYBBQUHAQEELzAtMCsGCCsGAQUFBzAChh9odHRwOi8vMTI3LjAuMC4x -OjgyMDAvdjEvcGtpL2NhMCEGA1UdEQQaMBiCEGNlcnQuZXhhbXBsZS5jb22HBH8A -AAEwMQYDVR0fBCowKDAmoCSgIoYgaHR0cDovLzEyNy4wLjAuMTo4MjAwL3YxL3Br -aS9jcmwwDQYJKoZIhvcNAQELBQADggEBABsuvmPSNjjKTVN6itWzdQy+SgMIrwfs -X1Yb9Lefkkwmp9ovKFNQxa4DucuCuzXcQrbKwWTfHGgR8ct4rf30xCRoA7dbQWq4 -aYqNKFWrRaBRAaaYZ/O1ApRTOrXqRx9Eqr0H1BXLsoAq+mWassL8sf6siae+CpwA -KqBko5G0dNXq5T4i2LQbmoQSVetIrCJEeMrU+idkuqfV2h1BQKgSEhFDABjFdTCN -QDAHsEHsi2M4/jRW9fqEuhHSDfl2n7tkFUI8wTHUUCl7gXwweJ4qtaSXIwKXYzNj -xqKHA8Purc1Yfybz4iE1JCROi9fInKlzr5xABq8nb9Qc/J9DIQM+Xmk= ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIIDPDCCAiSgAwIBAgIUb5id+GcaMeMnYBv3MvdTGWigyJ0wDQYJKoZIhvcNAQEL -BQAwFjEUMBIGA1UEAxMLZXhhbXBsZS5jb20wHhcNMTYwMjI5MDIyNzI5WhcNMjYw -MjI2MDIyNzU5WjAWMRQwEgYDVQQDEwtleGFtcGxlLmNvbTCCASIwDQYJKoZIhvcN -AQEBBQADggEPADCCAQoCggEBAOxTMvhTuIRc2YhxZpmPwegP86cgnqfT1mXxi1A7 -Q7qax24Nqbf00I3oDMQtAJlj2RB3hvRSCb0/lkF7i1Bub+TGxuM7NtZqp2F8FgG0 -z2md+W6adwW26rlxbQKjmRvMn66G9YPTkoJmPmxt2Tccb9+apmwW7lslL5j8H48x -AHJTMb+PMP9kbOHV5Abr3PT4jXUPUr/mWBvBiKiHG0Xd/HEmlyOEPeAThxK+I5tb -6m+eB+7cL9BsvQpy135+2bRAxUphvFi5NhryJ2vlAvoJ8UqigsNK3E28ut60FAoH -SWRfFUFFYtfPgTDS1yOKU/z/XMU2giQv2HrleWt0mp4jqBUCAwEAAaOBgTB/MA4G -A1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSdxLNP/ocx -7HK6JT3/sSAe76iTmzAfBgNVHSMEGDAWgBSdxLNP/ocx7HK6JT3/sSAe76iTmzAc -BgNVHREEFTATggtleGFtcGxlLmNvbYcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEA -wHThDRsXJunKbAapxmQ6bDxSvTvkLA6m97TXlsFgL+Q3Jrg9HoJCNowJ0pUTwhP2 -U946dCnSCkZck0fqkwVi4vJ5EQnkvyEbfN4W5qVsQKOFaFVzep6Qid4rZT6owWPa -cNNzNcXAee3/j6hgr6OQ/i3J6fYR4YouYxYkjojYyg+CMdn6q8BoV0BTsHdnw1/N -ScbnBHQIvIZMBDAmQueQZolgJcdOuBLYHe/kRy167z8nGg+PUFKIYOL8NaOU1+CJ -t2YaEibVq5MRqCbRgnd9a2vG0jr5a3Mn4CUUYv+5qIjP3hUusYenW1/EWtn1s/gk -zehNe5dFTjFpylg1o6b8Ow== ------END CERTIFICATE----- diff --git a/vault/diagnose/tls_verification_test.go b/vault/diagnose/tls_verification_test.go index 70f506c9377a..769330fb776d 100644 --- a/vault/diagnose/tls_verification_test.go +++ b/vault/diagnose/tls_verification_test.go @@ -5,23 +5,29 @@ package diagnose import ( "context" + "encoding/pem" "fmt" + "os" + "path/filepath" "strings" "testing" + pkihelper "github.com/hashicorp/vault/helper/testhelpers/pki" "github.com/hashicorp/vault/internalshared/configutil" ) // TestTLSValidCert is the positive test case to show that specifying a valid cert and key // passes all checks. func TestTLSValidCert(t *testing.T) { + tlsFiles := pkihelper.GenerateCertWithRoot(t) + listeners := []*configutil.Listener{ { Type: "tcp", Address: "127.0.0.1:443", ClusterAddress: "127.0.0.1:8201", - TLSCertFile: "./test-fixtures/goodcertwithroot.pem", - TLSKeyFile: "./test-fixtures/goodkey.pem", + TLSCertFile: tlsFiles.CombinedLeafCaFile, + TLSKeyFile: tlsFiles.Leaf.KeyFile, TLSMinVersion: "tls10", TLSDisableClientCerts: true, }, @@ -390,14 +396,15 @@ func TestTLSClientCAVerfiyMutualExclusion(t *testing.T) { // TestTLSClientCAVerfiy checks that a listener which has TLS client certs checks enabled works as expected func TestTLSClientCAFileCheck(t *testing.T) { + testCaFiles := pkihelper.GenerateCertWithRoot(t) listeners := []*configutil.Listener{ { Type: "tcp", Address: "127.0.0.1:443", ClusterAddress: "127.0.0.1:8201", - TLSCertFile: "./../../api/test-fixtures/keys/cert.pem", - TLSKeyFile: "./../../api/test-fixtures/keys/key.pem", - TLSClientCAFile: "./../../api/test-fixtures/root/rootcacert.pem", + TLSCertFile: testCaFiles.Leaf.CertFile, + TLSKeyFile: testCaFiles.Leaf.KeyFile, + TLSClientCAFile: testCaFiles.RootCa.CertFile, TLSMaxVersion: "tls10", TLSRequireAndVerifyClientCert: true, TLSDisableClientCerts: false, @@ -414,14 +421,25 @@ func TestTLSClientCAFileCheck(t *testing.T) { // TestTLSLeafCertInClientCAFile checks if a leafCert exist in TLSClientCAFile func TestTLSLeafCertInClientCAFile(t *testing.T) { + testCaFiles := pkihelper.GenerateCertWithRoot(t) + + tempDir := t.TempDir() + + otherRoot := pkihelper.GenerateRootCa(t) + mixedLeafWithRoot := filepath.Join(tempDir, "goodcertbadroot.pem") + err := os.WriteFile(mixedLeafWithRoot, append(pem.EncodeToMemory(testCaFiles.Leaf.CertPem), pem.EncodeToMemory(otherRoot.CertPem)...), 0o644) + if err != nil { + t.Fatalf("Failed to write file %s: %v", mixedLeafWithRoot, err) + } + listeners := []*configutil.Listener{ { Type: "tcp", Address: "127.0.0.1:443", ClusterAddress: "127.0.0.1:8201", - TLSCertFile: "./../../api/test-fixtures/keys/cert.pem", - TLSKeyFile: "./../../api/test-fixtures/keys/key.pem", - TLSClientCAFile: "./test-fixtures/goodcertbadroot.pem", + TLSCertFile: testCaFiles.CombinedLeafCaFile, + TLSKeyFile: testCaFiles.Leaf.KeyFile, + TLSClientCAFile: mixedLeafWithRoot, TLSMaxVersion: "tls10", TLSRequireAndVerifyClientCert: true, TLSDisableClientCerts: false, @@ -430,10 +448,10 @@ func TestTLSLeafCertInClientCAFile(t *testing.T) { warnings, errs := ListenerChecks(context.Background(), listeners) fmt.Println(warnings) if errs == nil || len(errs) != 1 { - t.Fatalf("TLS Config check on bad ClientCAFile certificate should fail once") + t.Fatalf("TLS Config check on bad ClientCAFile certificate should fail once: got %v", errs) } if warnings == nil || len(warnings) != 1 { - t.Fatalf("TLS Config check on bad ClientCAFile certificate should warn once") + t.Fatalf("TLS Config check on bad ClientCAFile certificate should warn once: got %v", warnings) } if !strings.Contains(warnings[0], "Found at least one leaf certificate in the CA certificate file.") { t.Fatalf("Bad error message: %s", warnings[0]) @@ -445,14 +463,15 @@ func TestTLSLeafCertInClientCAFile(t *testing.T) { // TestTLSNoRootInClientCAFile checks if no Root cert exist in TLSClientCAFile func TestTLSNoRootInClientCAFile(t *testing.T) { + testCa := pkihelper.GenerateCertWithIntermediaryRoot(t) listeners := []*configutil.Listener{ { Type: "tcp", Address: "127.0.0.1:443", ClusterAddress: "127.0.0.1:8201", - TLSCertFile: "./../../api/test-fixtures/keys/cert.pem", - TLSKeyFile: "./../../api/test-fixtures/keys/key.pem", - TLSClientCAFile: "./test-fixtures/intermediateCert.pem", + TLSCertFile: testCa.Leaf.CertFile, + TLSKeyFile: testCa.Leaf.KeyFile, + TLSClientCAFile: testCa.IntCa.CertFile, TLSMaxVersion: "tls10", TLSRequireAndVerifyClientCert: true, TLSDisableClientCerts: false, @@ -469,14 +488,15 @@ func TestTLSNoRootInClientCAFile(t *testing.T) { // TestTLSIntermediateCertInClientCAFile checks if an intermediate cert is included in TLSClientCAFile func TestTLSIntermediateCertInClientCAFile(t *testing.T) { + testCa := pkihelper.GenerateCertWithIntermediaryRoot(t) listeners := []*configutil.Listener{ { Type: "tcp", Address: "127.0.0.1:443", ClusterAddress: "127.0.0.1:8201", - TLSCertFile: "./../../api/test-fixtures/keys/cert.pem", - TLSKeyFile: "./../../api/test-fixtures/keys/key.pem", - TLSClientCAFile: "./test-fixtures/chain.crt.pem", + TLSCertFile: testCa.Leaf.CertFile, + TLSKeyFile: testCa.Leaf.KeyFile, + TLSClientCAFile: testCa.CombinedCaFile, TLSMaxVersion: "tls10", TLSRequireAndVerifyClientCert: true, TLSDisableClientCerts: false, @@ -491,16 +511,25 @@ func TestTLSIntermediateCertInClientCAFile(t *testing.T) { } } -// TestTLSMultipleRootInClietCACert checks if multiple roots included in TLSClientCAFile -func TestTLSMultipleRootInClietCACert(t *testing.T) { +// TestTLSMultipleRootInClientCACert checks if multiple roots included in TLSClientCAFile +func TestTLSMultipleRootInClientCACert(t *testing.T) { + testCa := pkihelper.GenerateCertWithRoot(t) + otherRoot := pkihelper.GenerateRootCa(t) + tempDir := t.TempDir() + mixedRoots := filepath.Join(tempDir, "twoRootCA.pem") + err := os.WriteFile(mixedRoots, append(pem.EncodeToMemory(testCa.RootCa.CertPem), pem.EncodeToMemory(otherRoot.CertPem)...), 0o644) + if err != nil { + t.Fatalf("Failed to write file %s: %v", mixedRoots, err) + } + listeners := []*configutil.Listener{ { Type: "tcp", Address: "127.0.0.1:443", ClusterAddress: "127.0.0.1:8201", - TLSCertFile: "./../../api/test-fixtures/keys/cert.pem", - TLSKeyFile: "./../../api/test-fixtures/keys/key.pem", - TLSClientCAFile: "./test-fixtures/twoRootCA.pem", + TLSCertFile: testCa.Leaf.CertFile, + TLSKeyFile: testCa.Leaf.KeyFile, + TLSClientCAFile: mixedRoots, TLSMinVersion: "tls10", TLSRequireAndVerifyClientCert: true, TLSDisableClientCerts: false, @@ -508,7 +537,7 @@ func TestTLSMultipleRootInClietCACert(t *testing.T) { } warnings, errs := ListenerChecks(context.Background(), listeners) if errs != nil { - t.Fatalf("TLS Config check on valid certificate should not fail") + t.Fatalf("TLS Config check on valid certificate should not fail got: %v", errs) } if warnings == nil { t.Fatalf("TLS Config check on valid but bad certificate should warn") From 703897b242330656285fe48556fa143a30a2208a Mon Sep 17 00:00:00 2001 From: Victor Rodriguez Date: Mon, 9 Dec 2024 14:50:22 +0100 Subject: [PATCH 29/45] Fix decryption of raft bootstrap challenge in multi-seal scenarios. (#29117) --- changelog/29117.txt | 3 +++ vault/core.go | 3 ++- vault/raft.go | 24 +++++++----------------- 3 files changed, 12 insertions(+), 18 deletions(-) create mode 100644 changelog/29117.txt diff --git a/changelog/29117.txt b/changelog/29117.txt new file mode 100644 index 000000000000..cd12b03551b6 --- /dev/null +++ b/changelog/29117.txt @@ -0,0 +1,3 @@ +```release-note:bug +core/seal (enterprise): Fix decryption of the raft bootstrap challenge when using seal high availability. +``` diff --git a/vault/core.go b/vault/core.go index d9e7ad62de50..fd9ac93126aa 100644 --- a/vault/core.go +++ b/vault/core.go @@ -234,7 +234,8 @@ type unlockInformation struct { } type raftInformation struct { - challenge *wrapping.BlobInfo + // challenge is in ciphertext + challenge []byte leaderClient *api.Client leaderBarrierConfig *SealConfig nonVoter bool diff --git a/vault/raft.go b/vault/raft.go index cfffcf10196a..bf8f223afc02 100644 --- a/vault/raft.go +++ b/vault/raft.go @@ -16,12 +16,10 @@ import ( "sync/atomic" "time" - "github.com/golang/protobuf/proto" "github.com/hashicorp/go-cleanhttp" "github.com/hashicorp/go-discover" discoverk8s "github.com/hashicorp/go-discover/provider/k8s" "github.com/hashicorp/go-hclog" - wrapping "github.com/hashicorp/go-kms-wrapping/v2" "github.com/hashicorp/go-secure-stdlib/tlsutil" "github.com/hashicorp/go-uuid" goversion "github.com/hashicorp/go-version" @@ -1029,13 +1027,8 @@ func (c *Core) getRaftChallenge(leaderInfo *raft.LeaderJoinInfo) (*raftInformati return nil, fmt.Errorf("error decoding raft bootstrap challenge: %w", err) } - eBlob := &wrapping.BlobInfo{} - if err := proto.Unmarshal(challengeRaw, eBlob); err != nil { - return nil, fmt.Errorf("error decoding raft bootstrap challenge: %w", err) - } - return &raftInformation{ - challenge: eBlob, + challenge: challengeRaw, leaderClient: apiClient, leaderBarrierConfig: &sealConfig, }, nil @@ -1353,15 +1346,6 @@ func (c *Core) joinRaftSendAnswer(ctx context.Context, sealAccess seal.Access, r return errors.New("raft is already initialized") } - multiWrapValue := &seal.MultiWrapValue{ - Generation: sealAccess.Generation(), - Slots: []*wrapping.BlobInfo{raftInfo.challenge}, - } - plaintext, _, err := sealAccess.Decrypt(ctx, multiWrapValue, nil) - if err != nil { - return fmt.Errorf("error decrypting challenge: %w", err) - } - parsedClusterAddr, err := url.Parse(c.ClusterAddr()) if err != nil { return fmt.Errorf("error parsing cluster address: %w", err) @@ -1377,6 +1361,12 @@ func (c *Core) joinRaftSendAnswer(ctx context.Context, sealAccess seal.Access, r } } + sealer := NewSealAccessSealer(sealAccess, c.logger, "bootstrap_challenge_read") + plaintext, err := sealer.Open(context.Background(), raftInfo.challenge) + if err != nil { + return fmt.Errorf("error decrypting challenge: %w", err) + } + answerReq := raftInfo.leaderClient.NewRequest("PUT", "/v1/sys/storage/raft/bootstrap/answer") if err := answerReq.SetJSONBody(map[string]interface{}{ "answer": base64.StdEncoding.EncodeToString(plaintext), From 5701c5b49277cec52a28d8d67033263d422ffe65 Mon Sep 17 00:00:00 2001 From: Rachel Culpepper <84159930+rculpepper@users.noreply.github.com> Date: Mon, 9 Dec 2024 10:58:46 -0600 Subject: [PATCH 30/45] add ce changes for ecdsa hybrid (#29123) --- builtin/logical/transit/path_keys.go | 59 ++++- builtin/logical/transit/path_keys_test.go | 36 +++ sdk/helper/keysutil/lock_manager.go | 15 ++ sdk/helper/keysutil/policy.go | 296 ++++++++++++---------- sdk/helper/keysutil/policy_test.go | 1 + 5 files changed, 273 insertions(+), 134 deletions(-) diff --git a/builtin/logical/transit/path_keys.go b/builtin/logical/transit/path_keys.go index e00ba9c51f95..3c7d16f74ed6 100644 --- a/builtin/logical/transit/path_keys.go +++ b/builtin/logical/transit/path_keys.go @@ -135,6 +135,16 @@ key.`, Description: `The parameter set to use. Applies to ML-DSA and SLH-DSA key types. For ML-DSA key types, valid values are 44, 65, or 87.`, }, + "hybrid_key_type_pqc": { + Type: framework.TypeString, + Description: `The key type of the post-quantum key to use for hybrid signature schemes. +Supported types are: ML-DSA.`, + }, + "hybrid_key_type_ec": { + Type: framework.TypeString, + Description: `The key type of the elliptic curve key to use for hybrid signature schemes. +Supported types are: ecdsa-p256, ecdsa-p384, ecdsa-p521.`, + }, }, Operations: map[logical.Operation]framework.OperationHandler{ @@ -184,6 +194,8 @@ func (b *backend) pathPolicyWrite(ctx context.Context, req *logical.Request, d * managedKeyName := d.Get("managed_key_name").(string) managedKeyId := d.Get("managed_key_id").(string) parameterSet := d.Get("parameter_set").(string) + pqcKeyType := d.Get("hybrid_key_type_pqc").(string) + ecKeyType := d.Get("hybrid_key_type_ec").(string) if autoRotatePeriod != 0 && autoRotatePeriod < time.Hour { return logical.ErrorResponse("auto rotate period must be 0 to disable or at least an hour"), nil @@ -241,6 +253,16 @@ func (b *backend) pathPolicyWrite(ctx context.Context, req *logical.Request, d * return logical.ErrorResponse(fmt.Sprintf("invalid parameter set %s for key type %s", parameterSet, keyType)), logical.ErrInvalidRequest } + polReq.ParameterSet = parameterSet + case "hybrid": + polReq.KeyType = keysutil.KeyType_HYBRID + + var err error + polReq.HybridConfig, err = getHybridKeyConfig(pqcKeyType, parameterSet, ecKeyType) + if err != nil { + return logical.ErrorResponse(fmt.Sprintf("invalid config for hybrid key: %s", err)), logical.ErrInvalidRequest + } + polReq.ParameterSet = parameterSet default: return logical.ErrorResponse(fmt.Sprintf("unknown key type %v", keyType)), logical.ErrInvalidRequest @@ -393,6 +415,11 @@ func (b *backend) formatKeyPolicy(p *keysutil.Policy, context []byte) (*logical. resp.Data["parameter_set"] = p.ParameterSet } + if p.Type == keysutil.KeyType_HYBRID { + resp.Data["hybrid_key_type_pqc"] = p.HybridConfig.PQCKeyType.String() + resp.Data["hybrid_key_type_ec"] = p.HybridConfig.ECKeyType.String() + } + switch p.Type { case keysutil.KeyType_AES128_GCM96, keysutil.KeyType_AES256_GCM96, keysutil.KeyType_ChaCha20_Poly1305: retKeys := map[string]int64{} @@ -401,7 +428,7 @@ func (b *backend) formatKeyPolicy(p *keysutil.Policy, context []byte) (*logical. } resp.Data["keys"] = retKeys - case keysutil.KeyType_ECDSA_P256, keysutil.KeyType_ECDSA_P384, keysutil.KeyType_ECDSA_P521, keysutil.KeyType_ED25519, keysutil.KeyType_RSA2048, keysutil.KeyType_RSA3072, keysutil.KeyType_RSA4096, keysutil.KeyType_ML_DSA: + case keysutil.KeyType_ECDSA_P256, keysutil.KeyType_ECDSA_P384, keysutil.KeyType_ECDSA_P521, keysutil.KeyType_ED25519, keysutil.KeyType_RSA2048, keysutil.KeyType_RSA3072, keysutil.KeyType_RSA4096, keysutil.KeyType_ML_DSA, keysutil.KeyType_HYBRID: retKeys := map[string]map[string]interface{}{} for k, v := range p.Keys { key := asymKey{ @@ -488,6 +515,36 @@ func (b *backend) pathPolicyDelete(ctx context.Context, req *logical.Request, d return nil, nil } +func getHybridKeyConfig(pqcKeyType, parameterSet, ecKeyType string) (keysutil.HybridKeyConfig, error) { + config := keysutil.HybridKeyConfig{} + + switch pqcKeyType { + case "ml-dsa": + config.PQCKeyType = keysutil.KeyType_ML_DSA + + if parameterSet != keysutil.ParameterSet_ML_DSA_44 && + parameterSet != keysutil.ParameterSet_ML_DSA_65 && + parameterSet != keysutil.ParameterSet_ML_DSA_87 { + return keysutil.HybridKeyConfig{}, fmt.Errorf("invalid parameter set %s for key type %s", parameterSet, pqcKeyType) + } + default: + return keysutil.HybridKeyConfig{}, fmt.Errorf("invalid PQC key type: %s", pqcKeyType) + } + + switch ecKeyType { + case "ecdsa-p256": + config.ECKeyType = keysutil.KeyType_ECDSA_P256 + case "ecdsa-p384": + config.ECKeyType = keysutil.KeyType_ECDSA_P384 + case "ecdsa-p521": + config.ECKeyType = keysutil.KeyType_ECDSA_P521 + default: + return keysutil.HybridKeyConfig{}, fmt.Errorf("invalid key type for hybrid key: %s", ecKeyType) + } + + return config, nil +} + const pathPolicyHelpSyn = `Managed named encryption keys` const pathPolicyHelpDesc = ` diff --git a/builtin/logical/transit/path_keys_test.go b/builtin/logical/transit/path_keys_test.go index 8219dcbfbff5..ed6e84583469 100644 --- a/builtin/logical/transit/path_keys_test.go +++ b/builtin/logical/transit/path_keys_test.go @@ -254,6 +254,42 @@ func TestTransit_CreateKey(t *testing.T) { creationParams: map[string]interface{}{"type": "ml-dsa", "parameter_set": "87"}, entOnly: true, }, + "Hybrid ML-DSA-44-ECDSA-P256": { + creationParams: map[string]interface{}{"type": "hybrid", "parameter_set": "44", "hybrid_key_type_ec": "ecdsa-p256", "hybrid_key_type_pqc": "ml-dsa"}, + entOnly: true, + }, + "Hybrid ML-DSA-44-ECDSA-P384": { + creationParams: map[string]interface{}{"type": "hybrid", "parameter_set": "44", "hybrid_key_type_ec": "ecdsa-p384", "hybrid_key_type_pqc": "ml-dsa"}, + entOnly: true, + }, + "Hybrid ML-DSA-44-ECDSA-P521": { + creationParams: map[string]interface{}{"type": "hybrid", "parameter_set": "44", "hybrid_key_type_ec": "ecdsa-p521", "hybrid_key_type_pqc": "ml-dsa"}, + entOnly: true, + }, + "Hybrid ML-DSA-65-ECDSA-P256": { + creationParams: map[string]interface{}{"type": "ml-dsa", "parameter_set": "65", "hybrid_key_type_ec": "ecdsa-p256", "hybrid_key_type_pqc": "ml-dsa"}, + entOnly: true, + }, + "Hybrid ML-DSA-65-ECDSA-P384": { + creationParams: map[string]interface{}{"type": "ml-dsa", "parameter_set": "65", "hybrid_key_type_ec": "ecdsa-p384", "hybrid_key_type_pqc": "ml-dsa"}, + entOnly: true, + }, + "Hybrid ML-DSA-65-ECDSA-P521": { + creationParams: map[string]interface{}{"type": "ml-dsa", "parameter_set": "65", "hybrid_key_type_ec": "ecdsa-p521", "hybrid_key_type_pqc": "ml-dsa"}, + entOnly: true, + }, + "Hybrid ML-DSA-87-ECDSA-P256": { + creationParams: map[string]interface{}{"type": "ml-dsa", "parameter_set": "87", "hybrid_key_type_ec": "ecdsa-p256", "hybrid_key_type_pqc": "ml-dsa"}, + entOnly: true, + }, + "Hybrid ML-DSA-87-ECDSA-P384": { + creationParams: map[string]interface{}{"type": "ml-dsa", "parameter_set": "87", "hybrid_key_type_ec": "ecdsa-p384", "hybrid_key_type_pqc": "ml-dsa"}, + entOnly: true, + }, + "Hybrid ML-DSA-87-ECDSA-P521": { + creationParams: map[string]interface{}{"type": "ml-dsa", "parameter_set": "87", "hybrid_key_type_ec": "ecdsa-p521", "hybrid_key_type_pqc": "ml-dsa"}, + entOnly: true, + }, "bad key type": { creationParams: map[string]interface{}{"type": "fake-key-type"}, shouldError: true, diff --git a/sdk/helper/keysutil/lock_manager.go b/sdk/helper/keysutil/lock_manager.go index 435c361df48b..6b601227790d 100644 --- a/sdk/helper/keysutil/lock_manager.go +++ b/sdk/helper/keysutil/lock_manager.go @@ -71,6 +71,14 @@ type PolicyRequest struct { // ParameterSet indicates the parameter set to use with ML-DSA and SLH-DSA keys ParameterSet string + + // HybridConfig contains the key types and parameters for hybrid keys + HybridConfig HybridKeyConfig +} + +type HybridKeyConfig struct { + PQCKeyType KeyType + ECKeyType KeyType } type LockManager struct { @@ -412,6 +420,12 @@ func (lm *LockManager) GetPolicy(ctx context.Context, req PolicyRequest, rand io return nil, false, fmt.Errorf("key derivation and convergent encryption not supported for keys of type %v", req.KeyType) } + case KeyType_HYBRID: + if req.Derived || req.Convergent { + cleanup() + return nil, false, fmt.Errorf("key derivation and convergent encryption not supported for keys of type %v", req.KeyType) + } + default: cleanup() return nil, false, fmt.Errorf("unsupported key type %v", req.KeyType) @@ -427,6 +441,7 @@ func (lm *LockManager) GetPolicy(ctx context.Context, req PolicyRequest, rand io AutoRotatePeriod: req.AutoRotatePeriod, KeySize: req.KeySize, ParameterSet: req.ParameterSet, + HybridConfig: req.HybridConfig, } if req.Derived { diff --git a/sdk/helper/keysutil/policy.go b/sdk/helper/keysutil/policy.go index 3e4be8b1cd71..534827acdd88 100644 --- a/sdk/helper/keysutil/policy.go +++ b/sdk/helper/keysutil/policy.go @@ -73,6 +73,7 @@ const ( KeyType_AES128_CMAC KeyType_AES256_CMAC KeyType_ML_DSA + KeyType_HYBRID // If adding to this list please update allTestKeyTypes in policy_test.go ) @@ -189,7 +190,7 @@ func (kt KeyType) DecryptionSupported() bool { func (kt KeyType) SigningSupported() bool { switch kt { - case KeyType_ECDSA_P256, KeyType_ECDSA_P384, KeyType_ECDSA_P521, KeyType_ED25519, KeyType_RSA2048, KeyType_RSA3072, KeyType_RSA4096, KeyType_MANAGED_KEY, KeyType_ML_DSA: + case KeyType_ECDSA_P256, KeyType_ECDSA_P384, KeyType_ECDSA_P521, KeyType_ED25519, KeyType_RSA2048, KeyType_RSA3072, KeyType_RSA4096, KeyType_MANAGED_KEY, KeyType_ML_DSA, KeyType_HYBRID: return true } return false @@ -241,7 +242,7 @@ func (kt KeyType) HMACSupported() bool { func (kt KeyType) IsPQC() bool { switch kt { - case KeyType_ML_DSA: + case KeyType_ML_DSA, KeyType_HYBRID: return true default: return false @@ -297,6 +298,8 @@ func (kt KeyType) String() string { return "aes256-cmac" case KeyType_ML_DSA: return "ml-dsa" + case KeyType_HYBRID: + return "hybrid" } return "[unknown]" @@ -570,6 +573,9 @@ type Policy struct { // ParameterSet indicates the parameter set to use with ML-DSA and SLH-DSA keys ParameterSet string + + // HybridConfig contains the key types and parameters for hybrid keys + HybridConfig HybridKeyConfig } func (p *Policy) Lock(exclusive bool) { @@ -1266,69 +1272,7 @@ func (p *Policy) SignWithOptions(ver int, context, input []byte, options *Signin switch p.Type { case KeyType_ECDSA_P256, KeyType_ECDSA_P384, KeyType_ECDSA_P521: - var curveBits int - var curve elliptic.Curve - switch p.Type { - case KeyType_ECDSA_P384: - curveBits = 384 - curve = elliptic.P384() - case KeyType_ECDSA_P521: - curveBits = 521 - curve = elliptic.P521() - default: - curveBits = 256 - curve = elliptic.P256() - } - - key := &ecdsa.PrivateKey{ - PublicKey: ecdsa.PublicKey{ - Curve: curve, - X: keyParams.EC_X, - Y: keyParams.EC_Y, - }, - D: keyParams.EC_D, - } - - r, s, err := ecdsa.Sign(rand.Reader, key, input) - if err != nil { - return nil, err - } - - switch marshaling { - case MarshalingTypeASN1: - // This is used by openssl and X.509 - sig, err = asn1.Marshal(ecdsaSignature{ - R: r, - S: s, - }) - if err != nil { - return nil, err - } - - case MarshalingTypeJWS: - // This is used by JWS - - // First we have to get the length of the curve in bytes. Although - // we only support 256 now, we'll do this in an agnostic way so we - // can reuse this marshaling if we support e.g. 521. Getting the - // number of bytes without rounding up would be 65.125 so we need - // to add one in that case. - keyLen := curveBits / 8 - if curveBits%8 > 0 { - keyLen++ - } - - // Now create the output array - sig = make([]byte, keyLen*2) - rb := r.Bytes() - sb := s.Bytes() - copy(sig[keyLen-len(rb):], rb) - copy(sig[2*keyLen-len(sb):], sb) - - default: - return nil, errutil.UserError{Err: "requested marshaling type is invalid"} - } - + sig, err = signWithECDSA(p.Type, keyParams, input, marshaling) case KeyType_ED25519: var key ed25519.PrivateKey @@ -1403,6 +1347,76 @@ func (p *Policy) SignWithOptions(ver int, context, input []byte, options *Signin return res, nil } +func signWithECDSA(keyType KeyType, keyParams KeyEntry, input []byte, marshaling MarshalingType) ([]byte, error) { + var curveBits int + var curve elliptic.Curve + switch keyType { + case KeyType_ECDSA_P256: + curveBits = 256 + curve = elliptic.P256() + case KeyType_ECDSA_P384: + curveBits = 384 + curve = elliptic.P384() + case KeyType_ECDSA_P521: + curveBits = 521 + curve = elliptic.P521() + default: + return nil, fmt.Errorf("invalid key type %s for ECDSA", keyType) + } + + key := &ecdsa.PrivateKey{ + PublicKey: ecdsa.PublicKey{ + Curve: curve, + X: keyParams.EC_X, + Y: keyParams.EC_Y, + }, + D: keyParams.EC_D, + } + + r, s, err := ecdsa.Sign(rand.Reader, key, input) + if err != nil { + return nil, err + } + + var sig []byte + switch marshaling { + case MarshalingTypeASN1: + // This is used by openssl and X.509 + sig, err = asn1.Marshal(ecdsaSignature{ + R: r, + S: s, + }) + if err != nil { + return nil, err + } + + case MarshalingTypeJWS: + // This is used by JWS + + // First we have to get the length of the curve in bytes. Although + // we only support 256 now, we'll do this in an agnostic way so we + // can reuse this marshaling if we support e.g. 521. Getting the + // number of bytes without rounding up would be 65.125 so we need + // to add one in that case. + keyLen := curveBits / 8 + if curveBits%8 > 0 { + keyLen++ + } + + // Now create the output array + sig = make([]byte, keyLen*2) + rb := r.Bytes() + sb := s.Bytes() + copy(sig[keyLen-len(rb):], rb) + copy(sig[2*keyLen-len(sb):], sb) + + default: + return nil, errutil.UserError{Err: "requested marshaling type is invalid"} + } + + return sig, nil +} + func (p *Policy) VerifySignature(context, input []byte, hashAlgorithm HashType, sigAlgorithm string, marshaling MarshalingType, sig string) (bool, error) { return p.VerifySignatureWithOptions(context, input, sig, &SigningOptions{ HashAlgorithm: hashAlgorithm, @@ -1465,49 +1479,11 @@ func (p *Policy) VerifySignatureWithOptions(context, input []byte, sig string, o switch p.Type { case KeyType_ECDSA_P256, KeyType_ECDSA_P384, KeyType_ECDSA_P521: - var curve elliptic.Curve - switch p.Type { - case KeyType_ECDSA_P384: - curve = elliptic.P384() - case KeyType_ECDSA_P521: - curve = elliptic.P521() - default: - curve = elliptic.P256() - } - - var ecdsaSig ecdsaSignature - - switch marshaling { - case MarshalingTypeASN1: - rest, err := asn1.Unmarshal(sigBytes, &ecdsaSig) - if err != nil { - return false, errutil.UserError{Err: "supplied signature is invalid"} - } - if rest != nil && len(rest) != 0 { - return false, errutil.UserError{Err: "supplied signature contains extra data"} - } - - case MarshalingTypeJWS: - paramLen := len(sigBytes) / 2 - rb := sigBytes[:paramLen] - sb := sigBytes[paramLen:] - ecdsaSig.R = new(big.Int) - ecdsaSig.R.SetBytes(rb) - ecdsaSig.S = new(big.Int) - ecdsaSig.S.SetBytes(sb) - } - - keyParams, err := p.safeGetKeyEntry(ver) + key, err := p.safeGetKeyEntry(ver) if err != nil { return false, err } - key := &ecdsa.PublicKey{ - Curve: curve, - X: keyParams.EC_X, - Y: keyParams.EC_Y, - } - - return ecdsa.Verify(key, input, ecdsaSig.R, ecdsaSig.S), nil + return verifyWithECDSA(p.Type, key, input, sigBytes, marshaling) case KeyType_ED25519: var pub ed25519.PublicKey @@ -1586,6 +1562,50 @@ func (p *Policy) VerifySignatureWithOptions(context, input []byte, sig string, o } } +func verifyWithECDSA(keyType KeyType, keyParams KeyEntry, input, sigBytes []byte, marshaling MarshalingType) (bool, error) { + var curve elliptic.Curve + switch keyType { + case KeyType_ECDSA_P256: + curve = elliptic.P256() + case KeyType_ECDSA_P384: + curve = elliptic.P384() + case KeyType_ECDSA_P521: + curve = elliptic.P521() + default: + return false, fmt.Errorf("invalid key type %s for ECDSA", keyType) + } + + var ecdsaSig ecdsaSignature + + switch marshaling { + case MarshalingTypeASN1: + rest, err := asn1.Unmarshal(sigBytes, &ecdsaSig) + if err != nil { + return false, errutil.UserError{Err: "supplied signature is invalid"} + } + if rest != nil && len(rest) != 0 { + return false, errutil.UserError{Err: "supplied signature contains extra data"} + } + + case MarshalingTypeJWS: + paramLen := len(sigBytes) / 2 + rb := sigBytes[:paramLen] + sb := sigBytes[paramLen:] + ecdsaSig.R = new(big.Int) + ecdsaSig.R.SetBytes(rb) + ecdsaSig.S = new(big.Int) + ecdsaSig.S.SetBytes(sb) + } + + key := &ecdsa.PublicKey{ + Curve: curve, + X: keyParams.EC_X, + Y: keyParams.EC_Y, + } + + return ecdsa.Verify(key, input, ecdsaSig.R, ecdsaSig.S), nil +} + func (p *Policy) Import(ctx context.Context, storage logical.Storage, key []byte, randReader io.Reader) error { return p.ImportPublicOrPrivate(ctx, storage, key, true, randReader) } @@ -1772,36 +1792,9 @@ func (p *Policy) RotateInMemory(randReader io.Reader) (retErr error) { } case KeyType_ECDSA_P256, KeyType_ECDSA_P384, KeyType_ECDSA_P521: - var curve elliptic.Curve - switch p.Type { - case KeyType_ECDSA_P384: - curve = elliptic.P384() - case KeyType_ECDSA_P521: - curve = elliptic.P521() - default: - curve = elliptic.P256() - } - - privKey, err := ecdsa.GenerateKey(curve, rand.Reader) - if err != nil { + if err = generateECDSAKey(p.Type, &entry); err != nil { return err } - entry.EC_D = privKey.D - entry.EC_X = privKey.X - entry.EC_Y = privKey.Y - derBytes, err := x509.MarshalPKIXPublicKey(privKey.Public()) - if err != nil { - return errwrap.Wrapf("error marshaling public key: {{err}}", err) - } - pemBlock := &pem.Block{ - Type: "PUBLIC KEY", - Bytes: derBytes, - } - pemBytes := pem.EncodeToMemory(pemBlock) - if pemBytes == nil || len(pemBytes) == 0 { - return fmt.Errorf("error PEM-encoding public key") - } - entry.FormattedPublicKey = string(pemBytes) case KeyType_ED25519: // Go uses a 64-byte private key for Ed25519 keys (private+public, each @@ -2758,3 +2751,40 @@ func (p *Policy) ValidateAndPersistCertificateChain(ctx context.Context, keyVers p.Keys[strconv.Itoa(keyVersion)] = keyEntry return p.Persist(ctx, storage) } + +func generateECDSAKey(keyType KeyType, entry *KeyEntry) error { + var curve elliptic.Curve + switch keyType { + case KeyType_ECDSA_P256: + curve = elliptic.P256() + case KeyType_ECDSA_P384: + curve = elliptic.P384() + case KeyType_ECDSA_P521: + curve = elliptic.P521() + default: + return fmt.Errorf("invalid key type %s for ECDSA", keyType) + } + + privKey, err := ecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + return err + } + entry.EC_D = privKey.D + entry.EC_X = privKey.X + entry.EC_Y = privKey.Y + derBytes, err := x509.MarshalPKIXPublicKey(privKey.Public()) + if err != nil { + return errwrap.Wrapf("error marshaling public key: {{err}}", err) + } + pemBlock := &pem.Block{ + Type: "PUBLIC KEY", + Bytes: derBytes, + } + pemBytes := pem.EncodeToMemory(pemBlock) + if pemBytes == nil || len(pemBytes) == 0 { + return fmt.Errorf("error PEM-encoding public key") + } + entry.FormattedPublicKey = string(pemBytes) + + return nil +} diff --git a/sdk/helper/keysutil/policy_test.go b/sdk/helper/keysutil/policy_test.go index dd125e0b88c1..7dfeec3ae58d 100644 --- a/sdk/helper/keysutil/policy_test.go +++ b/sdk/helper/keysutil/policy_test.go @@ -36,6 +36,7 @@ var allTestKeyTypes = []KeyType{ KeyType_AES256_GCM96, KeyType_ECDSA_P256, KeyType_ED25519, KeyType_RSA2048, KeyType_RSA4096, KeyType_ChaCha20_Poly1305, KeyType_ECDSA_P384, KeyType_ECDSA_P521, KeyType_AES128_GCM96, KeyType_RSA3072, KeyType_MANAGED_KEY, KeyType_HMAC, KeyType_AES128_CMAC, KeyType_AES256_CMAC, KeyType_ML_DSA, + KeyType_HYBRID, } func TestPolicy_KeyTypes(t *testing.T) { From 56fa43f73f7e5c08ff751170696139fc81a1bbd1 Mon Sep 17 00:00:00 2001 From: Steven Clark Date: Mon, 9 Dec 2024 13:39:00 -0500 Subject: [PATCH 31/45] Fix return certificate expiry time from NearExpiration (#29128) * Fix return certificate expiry time from NearExpiration - The duration returned from the NearExpiration is supposed to represent the time till expiry from now and not the calculated time a month from now. * Add cl * PR feedback --- changelog/29128.txt | 3 +++ vault/diagnose/tls_verification.go | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) create mode 100644 changelog/29128.txt diff --git a/changelog/29128.txt b/changelog/29128.txt new file mode 100644 index 000000000000..ce458a0800ff --- /dev/null +++ b/changelog/29128.txt @@ -0,0 +1,3 @@ +```release-note:bug +vault/diagnose: Fix time to expiration reporting within the TLS verification to not be a month off. +``` diff --git a/vault/diagnose/tls_verification.go b/vault/diagnose/tls_verification.go index 7632e69d522f..be5603fcd614 100644 --- a/vault/diagnose/tls_verification.go +++ b/vault/diagnose/tls_verification.go @@ -270,15 +270,17 @@ func TLSFileWarningChecks(leafCerts, interCerts, rootCerts []*x509.Certificate) return warnings, nil } -// NearExpiration returns a true if a certficate will expire in a month and false otherwise +// NearExpiration returns a true if a certificate will expire in a month +// and false otherwise, along with the duration until the certificate expires +// which can be a negative duration if the certificate has already expired. func NearExpiration(c *x509.Certificate) (bool, time.Duration) { - oneMonthFromNow := time.Now().Add(30 * 24 * time.Hour) - var timeToExpiry time.Duration - if oneMonthFromNow.After(c.NotAfter) { - timeToExpiry := oneMonthFromNow.Sub(c.NotAfter) - return true, timeToExpiry - } - return false, timeToExpiry + now := time.Now() + timeToExpiry := c.NotAfter.Sub(now) + + oneMonthFromNow := now.Add(30 * 24 * time.Hour) + isNearExpiration := oneMonthFromNow.After(c.NotAfter) + + return isNearExpiration, timeToExpiry } // TLSMutualExclusionCertCheck returns error if both TLSDisableClientCerts and TLSRequireAndVerifyClientCert are set From 59489a88821ff0431b1ec1b22208a92ecccce183 Mon Sep 17 00:00:00 2001 From: hc-github-team-secure-vault-core Date: Mon, 9 Dec 2024 14:58:16 -0700 Subject: [PATCH 32/45] Update vault-plugin-secrets-openldap to v0.14.4 (#29131) Co-authored-by: hc-github-team-secure-vault-ecosystem --- changelog/29131.txt | 3 +++ go.mod | 4 ++-- go.sum | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 changelog/29131.txt diff --git a/changelog/29131.txt b/changelog/29131.txt new file mode 100644 index 000000000000..f19e657a533c --- /dev/null +++ b/changelog/29131.txt @@ -0,0 +1,3 @@ +```release-note:change +secrets/openldap: Update plugin to v0.14.4 +``` diff --git a/go.mod b/go.mod index 9b998cd305a3..f7c566435529 100644 --- a/go.mod +++ b/go.mod @@ -100,7 +100,6 @@ require ( github.com/hashicorp/go-rootcerts v1.0.2 github.com/hashicorp/go-secure-stdlib/awsutil v0.3.0 github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 - github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.0 github.com/hashicorp/go-secure-stdlib/gatedwriter v0.1.1 github.com/hashicorp/go-secure-stdlib/kv-builder v0.1.2 github.com/hashicorp/go-secure-stdlib/mlock v0.1.3 @@ -151,7 +150,7 @@ require ( github.com/hashicorp/vault-plugin-secrets-kubernetes v0.9.0 github.com/hashicorp/vault-plugin-secrets-kv v0.20.0 github.com/hashicorp/vault-plugin-secrets-mongodbatlas v0.13.0 - github.com/hashicorp/vault-plugin-secrets-openldap v0.14.3 + github.com/hashicorp/vault-plugin-secrets-openldap v0.14.4 github.com/hashicorp/vault-plugin-secrets-terraform v0.10.0 github.com/hashicorp/vault-testing-stepwise v0.3.2 github.com/hashicorp/vault/api v1.15.0 @@ -239,6 +238,7 @@ require ( github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-viper/mapstructure/v2 v2.1.0 // indirect github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6 // indirect + github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.0 // indirect github.com/hashicorp/go-secure-stdlib/httputil v0.1.0 // indirect github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect diff --git a/go.sum b/go.sum index 8c38651d7f1b..22e28cf8c91b 100644 --- a/go.sum +++ b/go.sum @@ -1602,8 +1602,8 @@ github.com/hashicorp/vault-plugin-secrets-kv v0.20.0 h1:p1RVmd4x1rgGK0tN8DDu21J2 github.com/hashicorp/vault-plugin-secrets-kv v0.20.0/go.mod h1:bCpMggD3Z0+H+3dOmTCoQjBHC53jA08lPqOLmFrHBi8= github.com/hashicorp/vault-plugin-secrets-mongodbatlas v0.13.0 h1:BeDS7luTeOW0braIbtuyairFF8SEz7k3nvi9e+mJ2Ok= github.com/hashicorp/vault-plugin-secrets-mongodbatlas v0.13.0/go.mod h1:sprde+S70PBIbgOLUAKDxR+xNF714ksBBVh77O3hnWc= -github.com/hashicorp/vault-plugin-secrets-openldap v0.14.3 h1:HY8q7qVmhtBYiNa5K24wws72jPjwzkSuAt7LwkRcT8Q= -github.com/hashicorp/vault-plugin-secrets-openldap v0.14.3/go.mod h1:wqOf/QJqrrNXjnm0eLUnm5Ju9s/LIZUl6wEKmnFL9Uo= +github.com/hashicorp/vault-plugin-secrets-openldap v0.14.4 h1:BA5gf+itQ4FtEg4gyXvEZW0ioRCSUNnO3+XBrxDNi9A= +github.com/hashicorp/vault-plugin-secrets-openldap v0.14.4/go.mod h1:mdECWDLyILokYVpdBgwvHWkPJ+cEnSTxR6yDT0TBS98= github.com/hashicorp/vault-plugin-secrets-terraform v0.10.0 h1:YzOJrpuDRNrw5SQ4i7IEjedF40I/7ejupQy+gAyQ6Zg= github.com/hashicorp/vault-plugin-secrets-terraform v0.10.0/go.mod h1:j2nbB//xAQMD+5JivVDalwDEyzJY3AWzKIkw6k65xJQ= github.com/hashicorp/vault-testing-stepwise v0.3.2 h1:FCe0yrbK/hHiHqzu7utLcvCTTKjghWHyXwOQ2lxfoQM= From 5ba4fb3df603e672c53b3ca980a2f8de80dd4b76 Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Tue, 10 Dec 2024 09:31:09 -0800 Subject: [PATCH 33/45] UI: Decode Oracle database `connection_url` (#29114) * decode url in the serializer for oracle connection_url * add serializer test * add test for oracle * add test back, remove decode-url helper * update comment and test * link jiras VAULT-32830 VAULT-29785 * add changelog * add test --- changelog/29114.txt | 3 + ui/app/helpers/decode-uri.js | 12 -- ui/app/serializers/database/connection.js | 10 ++ .../components/database-connection.hbs | 2 +- .../secrets/backend/database/secret-test.js | 61 +++++++++- .../serializers/database/connection-test.js | 113 +++++++++++++++++- 6 files changed, 176 insertions(+), 25 deletions(-) create mode 100644 changelog/29114.txt delete mode 100644 ui/app/helpers/decode-uri.js diff --git a/changelog/29114.txt b/changelog/29114.txt new file mode 100644 index 000000000000..6900093c564d --- /dev/null +++ b/changelog/29114.txt @@ -0,0 +1,3 @@ +```release-note:bug +ui: Decode database url to fix editing failures for an oracle connection +``` \ No newline at end of file diff --git a/ui/app/helpers/decode-uri.js b/ui/app/helpers/decode-uri.js deleted file mode 100644 index be4f471770ab..000000000000 --- a/ui/app/helpers/decode-uri.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { helper as buildHelper } from '@ember/component/helper'; - -export function decodeUri(string) { - return decodeURI(string); -} - -export default buildHelper(decodeUri); diff --git a/ui/app/serializers/database/connection.js b/ui/app/serializers/database/connection.js index 92951ccf5da8..c1bbed7fd113 100644 --- a/ui/app/serializers/database/connection.js +++ b/ui/app/serializers/database/connection.js @@ -29,6 +29,16 @@ export default RESTSerializer.extend({ ...payload.data, ...payload.data.connection_details, }; + + // connection_details are spread above into the main body of response so we can remove redundant data + delete response.connection_details; + if (response?.connection_url) { + // this url can include interpolated data, such as: "{{username}}/{{password}}@localhost:1521/OraDoc.localhost" + // these curly brackets are returned by the API encoded: "%7B%7Busername%7D%7D/%7B%7Bpassword%7D%7D@localhost:1521/OraDoc.localhost" + // we decode here so the UI displays and submits the url in the correct format + response.connection_url = decodeURI(response.connection_url); + } + if (payload.data.root_credentials_rotate_statements) { response.root_rotation_statements = payload.data.root_credentials_rotate_statements; } diff --git a/ui/app/templates/components/database-connection.hbs b/ui/app/templates/components/database-connection.hbs index c7ef46b3f126..c1bafe8f2d14 100644 --- a/ui/app/templates/components/database-connection.hbs +++ b/ui/app/templates/components/database-connection.hbs @@ -353,7 +353,7 @@ @alwaysRender={{not (is-empty-value (get @model attr.name) hasDefault=defaultDisplay)}} @defaultShown={{defaultDisplay}} @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} - @value={{if (eq attr.name "connection_url") (decode-uri (get @model attr.name)) (get @model attr.name)}} + @value={{get @model attr.name}} /> {{/if}} {{/let}} diff --git a/ui/tests/acceptance/secrets/backend/database/secret-test.js b/ui/tests/acceptance/secrets/backend/database/secret-test.js index 638f7b3dccdf..612a70489d06 100644 --- a/ui/tests/acceptance/secrets/backend/database/secret-test.js +++ b/ui/tests/acceptance/secrets/backend/database/secret-test.js @@ -6,6 +6,7 @@ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { currentURL, settled, click, visit, fillIn, typeIn, waitFor } from '@ember/test-helpers'; +import { setupMirage } from 'ember-cli-mirage/test-support'; import { create } from 'ember-cli-page-object'; import { selectChoose } from 'ember-power-select/test-support'; import { clickTrigger } from 'ember-power-select/test-support/helpers'; @@ -226,6 +227,7 @@ const connectionTests = [ module('Acceptance | secrets/database/*', function (hooks) { setupApplicationTest(hooks); + setupMirage(hooks); hooks.beforeEach(async function () { this.backend = `database-testing`; @@ -337,9 +339,11 @@ module('Acceptance | secrets/database/*', function (hooks) { await visit('/vault/secrets'); }); } - test('database connection create and edit: vault-plugin-database-oracle', async function (assert) { + + // keep oracle as separate test because it relies on an external plugin that isn't rolled into the vault binary + // https://github.com/hashicorp/vault-plugin-database-oracle + test('database connection create: vault-plugin-database-oracle', async function (assert) { assert.expect(11); - // keep oracle as separate test because it behaves differently than the others const testCase = { name: 'oracle-connection', plugin: 'vault-plugin-database-oracle', @@ -380,7 +384,52 @@ module('Acceptance | secrets/database/*', function (hooks) { await connectionPage.connectionUrl(testCase.url); testCase.requiredFields(assert, testCase.plugin); // Cannot save without plugin mounted - // TODO: add fake server response for fuller test coverage + // Edit tested separately with mocked server response + }); + + test('database connection edit: vault-plugin-database-oracle', async function (assert) { + assert.expect(2); + const connectionName = 'oracle-connection'; + // mock API so we can test edit (without mounting external oracle plugin) + this.server.get(`/${this.backend}/config/${connectionName}`, () => { + return { + request_id: 'f869f23e-15c0-389b-82ac-84035a2b6079', + lease_id: '', + renewable: false, + lease_duration: 0, + data: { + allowed_roles: ['*'], + connection_details: { + backend: 'database', + connection_url: '%7B%7Busername%7D%7D/%7B%7Bpassword%7D%7D@//localhost:1521/ORCLPDB1', + max_connection_lifetime: '0s', + max_idle_connections: 0, + max_open_connections: 3, + username: 'VAULTADMIN', + }, + password_policy: '', + plugin_name: 'vault-plugin-database-oracle', + plugin_version: '', + root_credentials_rotate_statements: [], + verify_connection: true, + }, + wrap_info: null, + warnings: null, + auth: null, + mount_type: 'database', + }; + }); + + await visit(`/vault/secrets/${this.backend}/show/${connectionName}`); + const decoded = '{{username}}/{{password}}@//localhost:1521/ORCLPDB1'; + assert + .dom('[data-test-row-value="Connection URL"]') + .hasText(decoded, 'connection_url is decoded in display'); + + await connectionPage.edit(); + assert + .dom('[data-test-input="connection_url"]') + .hasValue(decoded, 'connection_url is decoded when editing'); }); test('Can create and delete a connection', async function (assert) { @@ -504,17 +553,17 @@ module('Acceptance | secrets/database/*', function (hooks) { await visit('/vault/secrets'); }); - test('connection_url must be decoded', async function (assert) { + test('connection_url is decoded', async function (assert) { const backend = this.backend; const connection = await newConnection( backend, 'mongodb-database-plugin', - '{{username}}/{{password}}@oracle-xe:1521/XEPDB1' + '{{username}}/{{password}}@mongo:1521/XEPDB1' ); await navToConnection(backend, connection); assert .dom('[data-test-row-value="Connection URL"]') - .hasText('{{username}}/{{password}}@oracle-xe:1521/XEPDB1'); + .hasText('{{username}}/{{password}}@mongo:1521/XEPDB1'); }); test('Role create form', async function (assert) { diff --git a/ui/tests/unit/serializers/database/connection-test.js b/ui/tests/unit/serializers/database/connection-test.js index 13b73871aa90..90445ea1e016 100644 --- a/ui/tests/unit/serializers/database/connection-test.js +++ b/ui/tests/unit/serializers/database/connection-test.js @@ -79,12 +79,6 @@ module('Unit | Serializer | database/connection', function (hooks) { const expectedResult = { allowed_roles: ['readonly'], backend: 'database', - connection_details: { - backend: 'database', - insecure: false, - url: 'https://localhost:9200', - username: 'root', - }, id: 'elastic-test', insecure: false, name: 'elastic-test', @@ -98,4 +92,111 @@ module('Unit | Serializer | database/connection', function (hooks) { }; assert.deepEqual(normalized, expectedResult, `Normalizes and flattens database response`); }); + + test('it should normalize values for the database type (oracle)', function (assert) { + const serializer = this.owner.lookup('serializer:database/connection'); + const normalized = serializer.normalizeSecrets({ + request_id: 'request-id', + lease_id: '', + renewable: false, + lease_duration: 0, + data: { + allowed_roles: ['*'], + connection_details: { + backend: 'database', + connection_url: '%7B%7Busername%7D%7D/%7B%7Bpassword%7D%7D@//localhost:1521/ORCLPDB1', + max_connection_lifetime: '0s', + max_idle_connections: 0, + max_open_connections: 3, + username: 'VAULTADMIN', + }, + password_policy: '', + plugin_name: 'vault-plugin-database-oracle', + plugin_version: '', + root_credentials_rotate_statements: [], + verify_connection: true, + }, + wrap_info: null, + warnings: null, + auth: null, + mount_type: 'database', + backend: 'database', + id: 'oracle-test', + }); + const expectedResult = { + allowed_roles: ['*'], + backend: 'database', + connection_url: '{{username}}/{{password}}@//localhost:1521/ORCLPDB1', + id: 'oracle-test', + max_connection_lifetime: '0s', + max_idle_connections: 0, + max_open_connections: 3, + name: 'oracle-test', + password_policy: '', + plugin_name: 'vault-plugin-database-oracle', + plugin_version: '', + root_credentials_rotate_statements: [], + root_rotation_statements: [], + username: 'VAULTADMIN', + verify_connection: true, + }; + assert.deepEqual(normalized, expectedResult, `Normalizes and flattens database response`); + }); + + test('it should normalize values if some params do not exist', function (assert) { + const serializer = this.owner.lookup('serializer:database/connection'); + const normalized = serializer.normalizeSecrets({ + request_id: 'request-id', + lease_id: '', + renewable: false, + lease_duration: 0, + data: { + allowed_roles: ['*'], + connection_details: { backend: 'database' }, // no connection_url param intentionally + plugin_name: 'vault-postgres-db', + }, + wrap_info: null, + warnings: null, + auth: null, + mount_type: 'database', + backend: 'database', + id: 'db-test', + }); + const expectedResult = { + allowed_roles: ['*'], + backend: 'database', + id: 'db-test', + name: 'db-test', + plugin_name: 'vault-postgres-db', + }; + assert.deepEqual(normalized, expectedResult, `Normalizes and flattens database response`); + }); + + test('it should fail gracefully if no connection_details', function (assert) { + const serializer = this.owner.lookup('serializer:database/connection'); + const normalized = serializer.normalizeSecrets({ + request_id: 'request-id', + lease_id: '', + renewable: false, + lease_duration: 0, + data: { + allowed_roles: ['*'], + plugin_name: 'vault-postgres-db', + }, + wrap_info: null, + warnings: null, + auth: null, + mount_type: 'database', + backend: 'database', + id: 'db-test', + }); + const expectedResult = { + allowed_roles: ['*'], + backend: 'database', + id: 'db-test', + name: 'db-test', + plugin_name: 'vault-postgres-db', + }; + assert.deepEqual(normalized, expectedResult, `Normalizes and flattens database response`); + }); }); From 42ca69628e0ba3ca591a83b96d598bc7e01517d4 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Tue, 10 Dec 2024 12:02:25 -0600 Subject: [PATCH 34/45] Reword raft challenge changelog (#29140) --- changelog/29117.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/29117.txt b/changelog/29117.txt index cd12b03551b6..97bcd1e9702e 100644 --- a/changelog/29117.txt +++ b/changelog/29117.txt @@ -1,3 +1,3 @@ ```release-note:bug -core/seal (enterprise): Fix decryption of the raft bootstrap challenge when using seal high availability. +core/seal (enterprise): Fix problem with nodes unable to join Raft clusters with Seal High Availability enabled. ``` From 537fc0f3eacdce6f291bb4def847880339ebc260 Mon Sep 17 00:00:00 2001 From: divyaac Date: Tue, 10 Dec 2024 11:54:07 -0800 Subject: [PATCH 35/45] Send Global Data From Secondary to Primary During Upgrade (#29137) * OSS Patch OSS Patch Fixing a build issue * Revert "OSS Patch" This reverts commit 2cce608b9e7ad7df64cb10f91208c142e6825c57. * OSS-Patch * Fix test issue --- vault/activity_log.go | 314 +++++++++------ vault/activity_log_test.go | 526 +++++++++++++------------ vault/activity_log_testing_util.go | 203 +++++++--- vault/activity_log_util.go | 12 + vault/activity_log_util_common.go | 69 +++- vault/activity_log_util_common_test.go | 16 + 6 files changed, 700 insertions(+), 440 deletions(-) diff --git a/vault/activity_log.go b/vault/activity_log.go index 1a9b23f4038d..90ebffdd3887 100644 --- a/vault/activity_log.go +++ b/vault/activity_log.go @@ -36,18 +36,20 @@ import ( const ( // activitySubPath is the directory under the system view where // the log will be stored. - activitySubPath = "counters/activity/" - activityEntityBasePath = "log/entity/" - activityTokenBasePath = "log/directtokens/" - activityTokenLocalBasePath = "local/" + activityTokenBasePath - activityQueryBasePath = "queries/" - activityConfigKey = "config" - activityIntentLogKey = "endofmonth" - activityGlobalPathPrefix = "global/" - activityLocalPathPrefix = "local/" + activitySubPath = "counters/activity/" + activityEntityBasePath = "log/entity/" + activityTokenBasePath = "log/directtokens/" + activityTokenLocalBasePath = "local/" + activityTokenBasePath + activityQueryBasePath = "queries/" + activityConfigKey = "config" + activityIntentLogKey = "endofmonth" + activityGlobalPathPrefix = "global/" + activityLocalPathPrefix = "local/" + activitySecondaryTempDataPathPrefix = "secondary/" activityACMERegenerationKey = "acme-regeneration" activityDeduplicationUpgradeKey = "deduplication-upgrade" + activitySecondaryDataRecCount = "secondary-data-received" // sketch for each month that stores hash of client ids distinctClientsBasePath = "log/distinctclients/" @@ -202,8 +204,6 @@ type ActivityLog struct { // Channel to signal global clients have received by the primary from the secondary, during upgrade to 1.19 dedupUpgradeGlobalClientsReceivedCh chan struct{} - // track whether the current cluster is in the middle of an upgrade to 1.19 - dedupClientsUpgradeComplete *atomic.Bool // track metadata and contents of the most recent log segment currentSegment segmentInfo @@ -237,9 +237,6 @@ type ActivityLog struct { // This channel is relevant for upgrades to 1.17. It indicates whether precomputed queries have been // generated for ACME clients. computationWorkerDone chan struct{} - // This channel is relevant for upgrades to 1.19+ (version with deduplication of clients) - // This indicates that paths that were used before 1.19 to store clients have been cleaned - oldStoragePathsCleaned chan struct{} // channel to indicate that a global clients have been // sent to the primary from a secondary @@ -256,6 +253,9 @@ type ActivityLog struct { globalPartialMonthClientTracker map[string]*activity.EntityRecord inprocessExport *atomic.Bool + // RetryUntilFalse is a test only attribute that allows us to run the sendPreviousMonthGlobalClientsWorker + // for as long as the test wants + RetryUntilFalse *atomic.Bool // clock is used to support manipulating time in unit and integration tests clock timeutil.Clock @@ -427,8 +427,8 @@ func NewActivityLog(core *Core, logger log.Logger, view *BarrierView, metrics me standbyGlobalFragmentsReceived: make([]*activity.LogFragment, 0), secondaryGlobalClientFragments: make([]*activity.LogFragment, 0), inprocessExport: atomic.NewBool(false), + RetryUntilFalse: atomic.NewBool(false), precomputedQueryWritten: make(chan struct{}), - dedupClientsUpgradeComplete: atomic.NewBool(false), } config, err := a.loadConfigOrDefault(core.activeContext) @@ -497,18 +497,14 @@ func (a *ActivityLog) saveCurrentSegmentToStorageLocked(ctx context.Context, for {"type", "client"}, }) - // Since we are the primary, store global clients - // Create fragments from global clients and store the segment - if ret := a.createCurrentSegmentFromFragments(ctx, globalFragments, &a.currentGlobalSegment, force, activityGlobalPathPrefix); ret != nil { - return ret + if a.hasDedupClientsUpgrade(ctx) { + // Since we are the primary, store global clients + // Create fragments from global clients and store the segment + if ret := a.createCurrentSegmentFromFragments(ctx, globalFragments, &a.currentGlobalSegment, force, activityGlobalPathPrefix); ret != nil { + return ret + } } - } else if !a.dedupClientsUpgradeComplete.Load() { - // We are the secondary, and an upgrade is in progress. In this case we will temporarily store the data at this old path - // This data will be garbage collected after the upgrade has completed - if ret := a.createCurrentSegmentFromFragments(ctx, globalFragments, &a.currentSegment, force, ""); ret != nil { - return ret - } } // If segment start time is zero, do not update or write @@ -540,8 +536,17 @@ func (a *ActivityLog) saveCurrentSegmentToStorageLocked(ctx context.Context, for }) } + allLocalFragments := append(standbyLocalFragments, localFragment) + + if !a.hasDedupClientsUpgrade(ctx) { + // In case an upgrade is in progress we will temporarily store the data at this old path + // This data will be garbage collected after the upgrade has completed + a.logger.Debug("upgrade to 1.19 or above is in progress. storing data at old storage path until upgrade is complete") + return a.createCurrentSegmentFromFragments(ctx, append(globalFragments, allLocalFragments...), &a.currentSegment, force, "") + } + // store local fragments - if ret := a.createCurrentSegmentFromFragments(ctx, append(standbyLocalFragments, localFragment), &a.currentLocalSegment, force, activityLocalPathPrefix); ret != nil { + if ret := a.createCurrentSegmentFromFragments(ctx, allLocalFragments, &a.currentLocalSegment, force, activityLocalPathPrefix); ret != nil { return ret } @@ -635,7 +640,7 @@ func (a *ActivityLog) createCurrentSegmentFromFragments(ctx context.Context, fra return nil } -func (a *ActivityLog) savePreviousTokenSegments(ctx context.Context, startTime int64, pathPrefix string, fragments []*activity.LogFragment) error { +func (a *ActivityLog) savePreviousTokenSegments(ctx context.Context, startTime int64, fragments []*activity.LogFragment) error { tokenByNamespace := make(map[string]uint64) for _, fragment := range fragments { // As of 1.9, a fragment should no longer have any NonEntityTokens. However @@ -660,7 +665,7 @@ func (a *ActivityLog) savePreviousTokenSegments(ctx context.Context, startTime i tokenCount: &activity.TokenCount{CountByNamespaceID: tokenByNamespace}, } - if _, err := a.saveSegmentEntitiesInternal(ctx, segmentToStore, false, pathPrefix); err != nil { + if _, err := a.saveSegmentTokensInternal(ctx, segmentToStore, false); err != nil { return err } return nil @@ -846,9 +851,9 @@ func (a *ActivityLog) availableTimesAtPath(ctx context.Context, onlyIncludeTimes return nil, err } out := make([]time.Time, 0) - for _, path := range paths { + for _, pathTime := range paths { // generate a set of unique start times - segmentTime, err := timeutil.ParseTimeFromPath(path) + segmentTime, err := timeutil.ParseTimeFromPath(pathTime) if err != nil { return nil, err } @@ -1035,56 +1040,21 @@ func (a *ActivityLog) loadCurrentClientSegment(ctx context.Context, startTime ti a.currentSegment.startTimestamp = startTime.Unix() // load current global segment - path := activityGlobalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(globalSegmentSequenceNumber, 10) - - out, err := a.readEntitySegmentAtPath(ctx, path) - if err != nil && !errors.Is(err, ErrEmptyResponse) { + clients, err := a.loadClientDataIntoSegment(ctx, activityGlobalPathPrefix, startTime, globalSegmentSequenceNumber, &a.currentGlobalSegment) + if err != nil { return err } - if out != nil { - if !a.core.perfStandby { - a.currentGlobalSegment = segmentInfo{ - startTimestamp: startTime.Unix(), - currentClients: &activity.EntityActivityLog{ - Clients: out.Clients, - }, - tokenCount: &activity.TokenCount{ - CountByNamespaceID: make(map[string]uint64), - }, - clientSequenceNumber: globalSegmentSequenceNumber, - } - } else { - // populate this for edge case checking (if end of month passes while background loading on standby) - a.currentGlobalSegment.startTimestamp = startTime.Unix() - } - for _, client := range out.Clients { - a.globalPartialMonthClientTracker[client.ClientID] = client - } + for _, entity := range clients { + a.globalPartialMonthClientTracker[entity.ClientID] = entity } // load current local segment - path = activityLocalPathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(localSegmentSequenceNumber, 10) - out, err = a.readEntitySegmentAtPath(ctx, path) - if err != nil && !errors.Is(err, ErrEmptyResponse) { + clients, err = a.loadClientDataIntoSegment(ctx, activityLocalPathPrefix, startTime, localSegmentSequenceNumber, &a.currentLocalSegment) + if err != nil { return err } - if out != nil { - if !a.core.perfStandby { - a.currentLocalSegment = segmentInfo{ - startTimestamp: startTime.Unix(), - currentClients: &activity.EntityActivityLog{ - Clients: out.Clients, - }, - tokenCount: a.currentLocalSegment.tokenCount, - clientSequenceNumber: localSegmentSequenceNumber, - } - } else { - // populate this for edge case checking (if end of month passes while background loading on standby) - a.currentLocalSegment.startTimestamp = startTime.Unix() - } - for _, client := range out.Clients { - a.partialMonthLocalClientTracker[client.ClientID] = client - } + for _, entity := range clients { + a.partialMonthLocalClientTracker[entity.ClientID] = entity } return nil @@ -1141,7 +1111,7 @@ func (a *ActivityLog) tokenCountExists(ctx context.Context, startTime time.Time) // loadTokenCount populates the in-memory representation of activity token count // this function should be called with the lock held -func (a *ActivityLog) loadTokenCount(ctx context.Context, startTime time.Time) error { +func (a *ActivityLog) loadTokenCount(ctx context.Context, startTime time.Time, segment *segmentInfo) error { tokenCountExists, err := a.tokenCountExists(ctx, startTime) if err != nil { return err @@ -1173,7 +1143,7 @@ func (a *ActivityLog) loadTokenCount(ctx context.Context, startTime time.Time) e // We must load the tokenCount of the current segment into the activity log // so that TWEs counted before the introduction of a client ID for TWEs are // still reported in the partial client counts. - a.currentLocalSegment.tokenCount = out + segment.tokenCount = out return nil } @@ -1202,8 +1172,8 @@ func (a *ActivityLog) entityBackgroundLoader(ctx context.Context, wg *sync.WaitG // Call with fragmentLock, globalFragmentLock, localFragmentLock and l held. func (a *ActivityLog) startNewCurrentLogLocked(now time.Time) { a.logger.Trace("initializing new log") - a.resetCurrentLog() - a.setCurrentSegmentTimeLocked(now) + // We will normalize times to start of the month to avoid errors + a.newMonthCurrentLogLocked(now) } // Should be called with fragmentLock, globalFragmentLock, localFragmentLock and l held. @@ -1239,6 +1209,10 @@ func (a *ActivityLog) setCurrentSegmentTimeLocked(t time.Time) { func (a *ActivityLog) resetCurrentLog() { // setting a.currentSegment timestamp to support upgrades a.currentSegment.startTimestamp = 0 + a.currentSegment.currentClients = &activity.EntityActivityLog{ + Clients: make([]*activity.EntityRecord, 0), + } + a.currentSegment.clientSequenceNumber = 0 // global segment a.currentGlobalSegment.startTimestamp = 0 @@ -1289,18 +1263,19 @@ func (a *ActivityLog) deleteLogWorker(ctx context.Context, startTimestamp int64, } func (a *ActivityLog) deleteOldStoragePathWorker(ctx context.Context, pathPrefix string) { - pathTimes, err := a.view.List(ctx, pathPrefix) + times, err := a.availableTimesAtPath(ctx, time.Now(), pathPrefix) if err != nil { a.logger.Error("could not list segment paths", "error", err) return } - for _, pathTime := range pathTimes { - segments, err := a.view.List(ctx, pathPrefix+pathTime) + for _, pathTime := range times { + pathWithTime := fmt.Sprintf("%s%d/", pathPrefix, pathTime.Unix()) + segments, err := a.view.List(ctx, pathWithTime) if err != nil { a.logger.Error("could not list segment path", "error", err) } for _, seqNum := range segments { - err = a.view.Delete(ctx, pathPrefix+pathTime+seqNum) + err = a.view.Delete(ctx, pathWithTime+seqNum) if err != nil { a.logger.Error("could not delete log", "error", err) } @@ -1335,6 +1310,19 @@ func (a *ActivityLog) refreshFromStoredLog(ctx context.Context, wg *sync.WaitGro a.localFragmentLock.Lock() defer a.localFragmentLock.Unlock() + // Garbage collect data at old storage paths + if a.hasDedupClientsUpgrade(ctx) { + a.deleteOldStoragePathWorker(ctx, activityEntityBasePath) + a.deleteOldStoragePathWorker(ctx, activityTokenBasePath) + secondaryIds, err := a.view.List(ctx, activitySecondaryTempDataPathPrefix) + if err != nil { + return err + } + for _, secondaryId := range secondaryIds { + a.deleteOldStoragePathWorker(ctx, activitySecondaryTempDataPathPrefix+secondaryId+activityEntityBasePath) + } + } + decreasingLogTimes, err := a.getMostRecentActivityLogSegment(ctx, now) if err != nil { return err @@ -1349,7 +1337,35 @@ func (a *ActivityLog) refreshFromStoredLog(ctx context.Context, wg *sync.WaitGro a.startNewCurrentLogLocked(now) } } + } + // If we have not finished upgrading, we will refresh currentSegment so data + // can be stored at the old paths until the upgrade is complete. + if !a.hasDedupClientsUpgrade(ctx) && !a.core.perfStandby { + times, err := a.availableTimesAtPath(ctx, now, activityEntityBasePath) + if err != nil { + return err + } + if len(times) > 0 { + mostRecentTimeOldEntityPath := times[len(times)-1] + // The most recent time is either the current month or the next month (if we missed the rotation perhaps) + if timeutil.IsCurrentMonth(mostRecentTimeOldEntityPath, now) { + // setting a.currentSegment timestamp to support upgrades + a.currentSegment.startTimestamp = mostRecentTimeOldEntityPath.Unix() + // This follows the logic in loadCurrentClientSegment + // We do not want need to set a clientSeq number of perf nodes because no client data is written on perf nodes, it is forwarded to the active node + if !a.core.perfStandby { + segmentNum, exists, err := a.getLastSegmentNumberByEntityPath(ctx, activityEntityBasePath+fmt.Sprint(mostRecentTimeOldEntityPath.Unix())+"/") + if err == nil && exists { + a.loadClientDataIntoSegment(ctx, "", mostRecentTimeOldEntityPath, segmentNum, &a.currentSegment) + } + } + } + } + } + + // We can exit before doing any further refreshing if we are in the middle of an upgrade or there are no logs + if len(decreasingLogTimes) == 0 || !a.hasDedupClientsUpgrade(ctx) { return nil } @@ -1395,7 +1411,7 @@ func (a *ActivityLog) refreshFromStoredLog(ctx context.Context, wg *sync.WaitGro // is still required since without it, we would lose replicated TWE counts for the // current segment. if !a.core.perfStandby { - err = a.loadTokenCount(ctx, mostRecent) + err = a.loadTokenCount(ctx, mostRecent, &a.currentLocalSegment) if err != nil { return err } @@ -1665,17 +1681,21 @@ func (c *Core) secondaryDuplicateClientMigrationWorker(ctx context.Context) { manager := c.activityLog manager.logger.Trace("started secondary activity log migration worker") storageMigrationComplete := atomic.NewBool(false) + globalClientDataSent := atomic.NewBool(false) wg := &sync.WaitGroup{} wg.Add(1) go func() { - if !c.IsPerfSecondary() { - // TODO: Create function for the secondary to continuously attempt to send data to the primary + defer wg.Done() + _, err := manager.sendPreviousMonthGlobalClientsWorker(ctx) + if err != nil { + manager.logger.Debug("failed to send previous months client data to primary", "error", err) + return } - - wg.Done() + globalClientDataSent.Store(true) }() wg.Add(1) go func() { + defer wg.Done() localClients, _, err := manager.extractLocalGlobalClientsDeprecatedStoragePath(ctx) if err != nil { return @@ -1690,31 +1710,46 @@ func (c *Core) secondaryDuplicateClientMigrationWorker(ctx context.Context) { return } } + + // Get tokens from previous months at old storage paths + clusterTokens, err := manager.extractTokensDeprecatedStoragePath(ctx) + + // Store tokens at new path + for month, tokenCount := range clusterTokens { + // Combine all token counts from all clusters + logFragments := make([]*activity.LogFragment, len(tokenCount)) + for i, tokens := range tokenCount { + logFragments[i] = &activity.LogFragment{NonEntityTokens: tokens} + } + if err = manager.savePreviousTokenSegments(ctx, month, logFragments); err != nil { + manager.logger.Error("failed to write token segment", "error", err, "month", month) + return + } + } + storageMigrationComplete.Store(true) // TODO: generate/store PCQs for these local clients - wg.Done() }() wg.Wait() if !storageMigrationComplete.Load() { manager.logger.Error("could not complete migration of duplicate clients on cluster") return } + if !globalClientDataSent.Load() { + manager.logger.Error("could not send global clients to the primary") + return + } // We have completed the vital portions of the storage migration if err := manager.writeDedupClientsUpgrade(ctx); err != nil { manager.logger.Error("could not complete migration of duplicate clients on cluster") return } - // Now that all the clients have been migrated and PCQs have been created, remove all clients at old storage paths - manager.oldStoragePathsCleaned = make(chan struct{}) - go func() { - defer close(manager.oldStoragePathsCleaned) - manager.deleteOldStoragePathWorker(ctx, activityEntityBasePath) - manager.deleteOldStoragePathWorker(ctx, activityTokenBasePath) - // TODO: Delete old PCQs - }() + // TODO: Delete old PCQs + + // Refresh activity log and load current month entities into memory + manager.refreshFromStoredLog(ctx, wg, time.Now().UTC()) - manager.dedupClientsUpgradeComplete.Store(true) manager.logger.Trace("completed secondary activity log migration worker") } @@ -1752,6 +1787,31 @@ func (a *ActivityLog) writeDedupClientsUpgrade(ctx context.Context) error { return a.view.Put(ctx, regeneratedEntry) } +func (a *ActivityLog) incrementSecondaryClientRecCount(ctx context.Context) error { + val, _ := a.getSecondaryClientRecCount(ctx) + val += 1 + regeneratedEntry, err := logical.StorageEntryJSON(activitySecondaryDataRecCount, val) + if err != nil { + return err + } + return a.view.Put(ctx, regeneratedEntry) +} + +func (a *ActivityLog) getSecondaryClientRecCount(ctx context.Context) (int, error) { + out, err := a.view.Get(ctx, activitySecondaryDataRecCount) + if err != nil { + return 0, err + } + if out == nil { + return 0, nil + } + var data int + if err = out.DecodeJSON(&data); err != nil { + return 0, err + } + return data, err +} + func (a *ActivityLog) regeneratePrecomputedQueries(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -1920,7 +1980,7 @@ func (a *ActivityLog) secondaryFragmentWorker(ctx context.Context) { } // Only send data if no upgrade is in progress. Else, the active worker will // store the data in a temporary location until it is garbage collected - if a.dedupClientsUpgradeComplete.Load() { + if a.hasDedupClientsUpgrade(ctx) { sendFunc() } @@ -1935,7 +1995,7 @@ func (a *ActivityLog) secondaryFragmentWorker(ctx context.Context) { } // If an upgrade is in progress, don't do anything // The active fragmentWorker will take care of flushing the clients to a temporary location - if a.dedupClientsUpgradeComplete.Load() { + if a.hasDedupClientsUpgrade(ctx) { sendFunc() // clear active entity set a.globalFragmentLock.Lock() @@ -4037,7 +4097,6 @@ func (c *Core) activityLogMigrationTask(ctx context.Context) { } else { // Store that upgrade processes have already been completed manager.writeDedupClientsUpgrade(ctx) - manager.dedupClientsUpgradeComplete.Store(true) } } else { // We kick off the secondary migration worker in any chance that the primary has not yet upgraded. @@ -4045,11 +4104,6 @@ func (c *Core) activityLogMigrationTask(ctx context.Context) { // already upgraded primary if !manager.hasDedupClientsUpgrade(ctx) { go c.secondaryDuplicateClientMigrationWorker(ctx) - } else { - // Store that upgrade processes have already been completed - manager.writeDedupClientsUpgrade(ctx) - manager.dedupClientsUpgradeComplete.Store(true) - } } } @@ -4062,10 +4116,11 @@ func (c *Core) activityLogMigrationTask(ctx context.Context) { func (c *Core) primaryDuplicateClientMigrationWorker(ctx context.Context) error { a := c.activityLog a.logger.Trace("started primary activity log migration worker") + ctx, cancel := context.WithCancel(ctx) + defer cancel() // Collect global clients from secondary - err := a.waitForSecondaryGlobalClients(ctx) - if err != nil { + if err := a.waitForSecondaryGlobalClients(ctx); err != nil { return err } @@ -4077,8 +4132,36 @@ func (c *Core) primaryDuplicateClientMigrationWorker(ctx context.Context) error } // Get tokens from previous months at old storage paths clusterTokens, err := a.extractTokensDeprecatedStoragePath(ctx) + if err != nil { + return nil + } - // TODO: Collect clients from secondaries into slice of fragments + // Collect global clients from secondaries and put them in the clusterGlobalClients map + secondaryIds, err := a.view.List(ctx, activitySecondaryTempDataPathPrefix) + if err != nil { + return err + } + for _, secondaryId := range secondaryIds { + times, err := a.availableTimesAtPath(ctx, time.Now(), activitySecondaryTempDataPathPrefix+secondaryId+activityEntityBasePath) + if err != nil { + a.logger.Error("could not list secondary cluster clients until for cluster", "cluster", secondaryId) + return err + } + for _, time := range times { + segments, err := a.getAllEntitySegmentsForMonth(ctx, activitySecondaryTempDataPathPrefix+secondaryId+activityEntityBasePath, time.Unix()) + if err != nil { + return err + } + for _, segment := range segments { + for _, entity := range segment.GetClients() { + if _, ok := clusterGlobalClients[time.Unix()]; !ok { + clusterGlobalClients[time.Unix()] = make([]*activity.EntityRecord, 0) + } + clusterGlobalClients[time.Unix()] = append(clusterGlobalClients[time.Unix()], entity) + } + } + } + } // Store global clients at new path for month, entitiesForMonth := range clusterGlobalClients { @@ -4107,7 +4190,7 @@ func (c *Core) primaryDuplicateClientMigrationWorker(ctx context.Context) error for i, tokens := range tokenCount { logFragments[i] = &activity.LogFragment{NonEntityTokens: tokens} } - if err = a.savePreviousTokenSegments(ctx, month, activityLocalPathPrefix+activityTokenBasePath, logFragments); err != nil { + if err = a.savePreviousTokenSegments(ctx, month, logFragments); err != nil { a.logger.Error("failed to write token segment", "error", err, "month", month) return err } @@ -4119,15 +4202,12 @@ func (c *Core) primaryDuplicateClientMigrationWorker(ctx context.Context) error a.logger.Error("could not complete migration of duplicate clients on cluster") return err } - // Garbage collect data at old paths - a.oldStoragePathsCleaned = make(chan struct{}) - go func() { - defer close(a.oldStoragePathsCleaned) - a.deleteOldStoragePathWorker(ctx, activityEntityBasePath) - a.deleteOldStoragePathWorker(ctx, activityTokenBasePath) - // We will also need to delete old PCQs - }() - a.dedupClientsUpgradeComplete.Store(true) + + // TODO: We will also need to delete old PCQs + + // Refresh activity log and load current month entities into memory + a.refreshFromStoredLog(ctx, &sync.WaitGroup{}, time.Now().UTC()) + a.logger.Trace("completed primary activity log migration worker") return nil } diff --git a/vault/activity_log_test.go b/vault/activity_log_test.go index 8599592d8007..314cb22c2c46 100644 --- a/vault/activity_log_test.go +++ b/vault/activity_log_test.go @@ -12,9 +12,7 @@ import ( "io" "net/http" "reflect" - "sort" "strconv" - "strings" "sync" "testing" "time" @@ -1372,69 +1370,6 @@ func TestActivityLog_tokenCountExists(t *testing.T) { } } -// entityRecordsEqual compares the parts we care about from two activity entity record slices -// note: this makes a copy of the []*activity.EntityRecord so that misordered slices won't fail the comparison, -// but the function won't modify the order of the slices to compare -func entityRecordsEqual(t *testing.T, record1, record2 []*activity.EntityRecord) bool { - t.Helper() - - if record1 == nil { - return record2 == nil - } - if record2 == nil { - return record1 == nil - } - - if len(record1) != len(record2) { - return false - } - - // sort first on namespace, then on ID, then on timestamp - entityLessFn := func(e []*activity.EntityRecord, i, j int) bool { - ei := e[i] - ej := e[j] - - nsComp := strings.Compare(ei.NamespaceID, ej.NamespaceID) - if nsComp == -1 { - return true - } - if nsComp == 1 { - return false - } - - idComp := strings.Compare(ei.ClientID, ej.ClientID) - if idComp == -1 { - return true - } - if idComp == 1 { - return false - } - - return ei.Timestamp < ej.Timestamp - } - - entitiesCopy1 := make([]*activity.EntityRecord, len(record1)) - entitiesCopy2 := make([]*activity.EntityRecord, len(record2)) - copy(entitiesCopy1, record1) - copy(entitiesCopy2, record2) - - sort.Slice(entitiesCopy1, func(i, j int) bool { - return entityLessFn(entitiesCopy1, i, j) - }) - sort.Slice(entitiesCopy2, func(i, j int) bool { - return entityLessFn(entitiesCopy2, i, j) - }) - - for i, a := range entitiesCopy1 { - b := entitiesCopy2[i] - if a.ClientID != b.ClientID || a.NamespaceID != b.NamespaceID || a.Timestamp != b.Timestamp { - return false - } - } - - return true -} - func (a *ActivityLog) resetEntitiesInMemory(t *testing.T) { t.Helper() @@ -1586,7 +1521,7 @@ func TestActivityLog_loadCurrentClientSegment(t *testing.T) { } currentGlobalEntities := a.GetCurrentGlobalEntities() - if !entityRecordsEqual(t, currentGlobalEntities.Clients, tc.entities.Clients) { + if !EntityRecordsEqual(t, currentGlobalEntities.Clients, tc.entities.Clients) { t.Errorf("bad data loaded. expected: %v, got: %v for path %q", tc.entities.Clients, currentGlobalEntities, tc.path) } @@ -1742,7 +1677,7 @@ func TestActivityLog_loadTokenCount(t *testing.T) { } for _, tc := range testCases { - err := a.loadTokenCount(ctx, time.Unix(tc.time, 0)) + err := a.loadTokenCount(ctx, time.Unix(tc.time, 0), &a.currentLocalSegment) if err != nil { t.Fatalf("got error loading data for %q: %v", tc.path, err) } @@ -1810,13 +1745,99 @@ func TestActivityLog_StopAndRestart(t *testing.T) { } } +func addActivityRecordsOldStoragePath(t *testing.T, core *Core, base time.Time, includeEntities, includeTokens bool) (*ActivityLog, []*activity.EntityRecord, map[string]uint64) { + t.Helper() + + monthsAgo := base.AddDate(0, -3, 0) + a := core.activityLog + var entityRecords []*activity.EntityRecord + if includeEntities { + entityRecords = []*activity.EntityRecord{ + { + ClientID: "11111111-1111-1111-1111-111111111111", + NamespaceID: namespace.RootNamespaceID, + Timestamp: time.Now().Unix(), + }, + { + ClientID: "22222222-2222-2222-2222-222222222222", + NamespaceID: namespace.RootNamespaceID, + Timestamp: time.Now().Unix(), + }, + { + ClientID: "33333333-2222-2222-2222-222222222222", + NamespaceID: namespace.RootNamespaceID, + Timestamp: time.Now().Unix(), + }, + } + if constants.IsEnterprise { + entityRecords = append(entityRecords, []*activity.EntityRecord{ + { + ClientID: "44444444-1111-1111-1111-111111111111", + NamespaceID: "ns1", + Timestamp: time.Now().Unix(), + }, + }...) + } + + // append some local entity data + entityRecords = append(entityRecords, &activity.EntityRecord{ + ClientID: "44444444-4444-4444-4444-444444444444", + NamespaceID: namespace.RootNamespaceID, + Timestamp: time.Now().Unix(), + }) + + for i, entityRecord := range entityRecords { + entityData, err := proto.Marshal(&activity.EntityActivityLog{ + Clients: []*activity.EntityRecord{entityRecord}, + }) + if err != nil { + t.Fatalf(err.Error()) + } + switch i { + case 0: + WriteToStorage(t, core, ActivityPrefix+activityEntityBasePath+fmt.Sprint(monthsAgo.Unix())+"/0", entityData) + + case len(entityRecords) - 1: + // local data + WriteToStorage(t, core, ActivityPrefix+activityEntityBasePath+fmt.Sprint(base.Unix())+"/"+strconv.Itoa(i-1), entityData) + default: + WriteToStorage(t, core, ActivityPrefix+activityEntityBasePath+fmt.Sprint(base.Unix())+"/"+strconv.Itoa(i-1), entityData) + } + } + } + + var tokenRecords map[string]uint64 + if includeTokens { + tokenRecords = make(map[string]uint64) + tokenRecords[namespace.RootNamespaceID] = uint64(1) + if constants.IsEnterprise { + for i := 1; i < 4; i++ { + nsID := "ns" + strconv.Itoa(i) + tokenRecords[nsID] = uint64(i) + } + } + tokenCount := &activity.TokenCount{ + CountByNamespaceID: tokenRecords, + } + + tokenData, err := proto.Marshal(tokenCount) + if err != nil { + t.Fatalf(err.Error()) + } + + WriteToStorage(t, core, ActivityPrefix+activityTokenBasePath+fmt.Sprint(base.Unix())+"/0", tokenData) + } + + return a, entityRecords, tokenRecords +} + // :base: is the timestamp to start from for the setup logic (use to simulate newest log from past or future) // entity records returned include [0] data from a previous month and [1:] data from the current month // token counts returned are from the current month -func setupActivityRecordsInStorage(t *testing.T, base time.Time, includeEntities, includeTokens bool) (*ActivityLog, []*activity.EntityRecord, map[string]uint64) { +func setupActivityRecordsInStorage(t *testing.T, base time.Time, includeEntities, includeTokens, addOldStoragePathData bool) (*ActivityLog, []*activity.EntityRecord, map[string]uint64) { t.Helper() - core, _, _ := TestCoreUnsealed(t) + core, _, _ := TestCoreUnsealedWithConfig(t, &CoreConfig{ActivityLogConfig: ActivityLogCoreConfig{ForceEnable: true}}) a := core.activityLog monthsAgo := base.AddDate(0, -3, 0) @@ -1898,13 +1919,17 @@ func setupActivityRecordsInStorage(t *testing.T, base time.Time, includeEntities WriteToStorage(t, core, ActivityLogLocalPrefix+"directtokens/"+fmt.Sprint(base.Unix())+"/0", tokenData) } + if addOldStoragePathData { + return addActivityRecordsOldStoragePath(t, core, base, includeEntities, includeTokens) + } return a, entityRecords, tokenRecords } -// TestActivityLog_refreshFromStoredLog writes records for 3 months ago and this month, then calls refreshFromStoredLog. +// TestActivityLog_refreshFromStoredLog_DedupUpgradeComplete writes records for 3 months ago and this month, then calls refreshFromStoredLog. +// The system believes the upgrade to 1.19+ is already complete. It should not refresh data from old storage paths, only data at the new storage paths. // The test verifies that current entities and current tokens are correct. -func TestActivityLog_refreshFromStoredLog(t *testing.T) { - a, expectedClientRecords, expectedTokenCounts := setupActivityRecordsInStorage(t, time.Now().UTC(), true, true) +func TestActivityLog_refreshFromStoredLog_DedupUpgradeComplete(t *testing.T) { + a, expectedClientRecords, expectedTokenCounts := setupActivityRecordsInStorage(t, time.Now().UTC(), true, true, true) a.SetEnable(true) var wg sync.WaitGroup @@ -1933,13 +1958,101 @@ func TestActivityLog_refreshFromStoredLog(t *testing.T) { } currentEntities := a.GetCurrentGlobalEntities() - if !entityRecordsEqual(t, currentEntities.Clients, expectedCurrent.Clients) { + if !EntityRecordsEqual(t, currentEntities.Clients, expectedCurrent.Clients) { + // we only expect the newest entity segment to be loaded (for the current month) + t.Errorf("bad activity entity logs loaded. expected: %v got: %v", expectedCurrent, currentEntities) + } + + currentLocalEntities := a.GetCurrentLocalEntities() + if !EntityRecordsEqual(t, currentLocalEntities.Clients, expectedCurrentLocal.Clients) { + // we only expect the newest local entity segment to be loaded (for the current month) + t.Errorf("bad activity entity logs loaded. expected: %v got: %v", expectedCurrentLocal, currentLocalEntities) + } + + nsCount := a.GetStoredTokenCountByNamespaceID() + require.Equal(t, nsCount, expectedTokenCounts) + + activeClients := a.core.GetActiveClientsList() + if err := ActiveEntitiesEqual(activeClients, expectedActive.Clients); err != nil { + // we expect activeClients to be loaded for the entire month + t.Errorf("bad data loaded into active entities. expected only set of EntityID from %v in %v: %v", expectedActive.Clients, activeClients, err) + } + + // verify active global clients list + activeGlobalClients := a.core.GetActiveGlobalClientsList() + if err := ActiveEntitiesEqual(activeGlobalClients, expectedActiveGlobal.Clients); err != nil { + // we expect activeClients to be loaded for the entire month + t.Errorf("bad data loaded into active global entities. expected only set of EntityID from %v in %v: %v", expectedActiveGlobal.Clients, activeGlobalClients, err) + } + // verify active local clients list + activeLocalClients := a.core.GetActiveLocalClientsList() + if err := ActiveEntitiesEqual(activeLocalClients, expectedCurrentLocal.Clients); err != nil { + // we expect activeClients to be loaded for the entire month + t.Errorf("bad data loaded into active local entities. expected only set of EntityID from %v in %v: %v", expectedCurrentLocal.Clients, activeLocalClients, err) + } + + // No data from the old storage paths should have been loaded because the system believes that the upgrade was already complete + a.ExpectOldSegmentRefreshed(t, time.Now().UTC().Unix(), false, []*activity.EntityRecord{}, map[string]uint64{}) +} + +// TestActivityLog_refreshFromStoredLog_DedupUpgradeIncomplete writes records for 3 months ago and this month, then calls refreshFromStoredLog. +// The system thinks the upgrade to 1.19+ is incomplete. It should not refresh data from new storage paths, only data at the old storage paths. +// The test verifies that current entities and current tokens are correct. +func TestActivityLog_refreshFromStoredLog_DedupUpgradeIncomplete(t *testing.T) { + a, expectedClientRecords, expectedTokenCounts := setupActivityRecordsInStorage(t, time.Now().UTC(), true, true, true) + a.SetEnable(true) + + // Reset the system to state where the upgrade is incomplete + a.ResetDedupUpgrade(context.Background()) + + var wg sync.WaitGroup + now := time.Now().UTC() + err := a.refreshFromStoredLog(context.Background(), &wg, now) + if err != nil { + t.Fatalf("got error loading stored activity logs: %v", err) + } + wg.Wait() + + // active clients for the entire month + expectedActive := &activity.EntityActivityLog{ + Clients: expectedClientRecords[1:], + } + + // global clients added to the newest local entity segment + expectedCurrent := &activity.EntityActivityLog{ + Clients: expectedClientRecords[len(expectedClientRecords)-2 : len(expectedClientRecords)-1], + } + + expectedActiveGlobal := &activity.EntityActivityLog{ + Clients: expectedClientRecords[1 : len(expectedClientRecords)-1], + } + + // local client is only added to the newest segment for the current month. This should also appear in the active clients for the entire month. + expectedCurrentLocal := &activity.EntityActivityLog{ + Clients: expectedClientRecords[len(expectedClientRecords)-1:], + } + + // Data should be loaded into the old segment + a.ExpectOldSegmentRefreshed(t, now.Unix(), false, expectedCurrentLocal.GetClients(), map[string]uint64{}) + a.ExpectCurrentSegmentsRefreshed(t, timeutil.StartOfMonth(now).Unix(), false) + + // Simulate the completion of an upgrade + a.writeDedupClientsUpgrade(context.Background()) + + err = a.refreshFromStoredLog(context.Background(), &wg, now) + if err != nil { + t.Fatalf("got error loading stored activity logs: %v", err) + } + wg.Wait() + + currentEntities := a.GetCurrentGlobalEntities() + if !EntityRecordsEqual(t, currentEntities.Clients, expectedCurrent.Clients) { // we only expect the newest entity segment to be loaded (for the current month) t.Errorf("bad activity entity logs loaded. expected: %v got: %v", expectedCurrent, currentEntities) } currentLocalEntities := a.GetCurrentLocalEntities() - if !entityRecordsEqual(t, currentLocalEntities.Clients, expectedCurrentLocal.Clients) { + if !EntityRecordsEqual(t, currentLocalEntities.Clients, expectedCurrentLocal.Clients) { // we only expect the newest local entity segment to be loaded (for the current month) t.Errorf("bad activity entity logs loaded. expected: %v got: %v", expectedCurrentLocal, currentLocalEntities) } @@ -1974,7 +2087,7 @@ func TestActivityLog_refreshFromStoredLog(t *testing.T) { // test closes a.doneCh and calls refreshFromStoredLog, which will not do any processing because the doneCh is closed. // The test verifies that the current data is not loaded. func TestActivityLog_refreshFromStoredLogWithBackgroundLoadingCancelled(t *testing.T) { - a, expectedClientRecords, expectedTokenCounts := setupActivityRecordsInStorage(t, time.Now().UTC(), true, true) + a, expectedClientRecords, expectedTokenCounts := setupActivityRecordsInStorage(t, time.Now().UTC(), true, true, false) a.SetEnable(true) var wg sync.WaitGroup @@ -2007,13 +2120,13 @@ func TestActivityLog_refreshFromStoredLogWithBackgroundLoadingCancelled(t *testi } currentEntities := a.GetCurrentGlobalEntities() - if !entityRecordsEqual(t, currentEntities.Clients, expectedCurrent.Clients) { + if !EntityRecordsEqual(t, currentEntities.Clients, expectedCurrent.Clients) { // we only expect the newest entity segment to be loaded (for the current month) t.Errorf("bad activity entity logs loaded. expected: %v got: %v", expectedCurrent, currentEntities) } currentLocalEntities := a.GetCurrentLocalEntities() - if !entityRecordsEqual(t, currentLocalEntities.Clients, expectedCurrentLocal.Clients) { + if !EntityRecordsEqual(t, currentLocalEntities.Clients, expectedCurrentLocal.Clients) { // we only expect the newest local entity segment to be loaded (for the current month) t.Errorf("bad activity entity logs loaded. expected: %v got: %v", expectedCurrentLocal, currentLocalEntities) } @@ -2046,7 +2159,7 @@ func TestActivityLog_refreshFromStoredLogWithBackgroundLoadingCancelled(t *testi // TestActivityLog_refreshFromStoredLogContextCancelled writes data from 3 months ago to this month and calls // refreshFromStoredLog with a canceled context, verifying that the function errors because of the canceled context. func TestActivityLog_refreshFromStoredLogContextCancelled(t *testing.T) { - a, _, _ := setupActivityRecordsInStorage(t, time.Now().UTC(), true, true) + a, _, _ := setupActivityRecordsInStorage(t, time.Now().UTC(), true, true, false) var wg sync.WaitGroup ctx, cancelFn := context.WithCancel(context.Background()) @@ -2061,7 +2174,7 @@ func TestActivityLog_refreshFromStoredLogContextCancelled(t *testing.T) { // TestActivityLog_refreshFromStoredLogNoTokens writes only entities from 3 months ago to today, then calls // refreshFromStoredLog. It verifies that there are no tokens loaded. func TestActivityLog_refreshFromStoredLogNoTokens(t *testing.T) { - a, expectedClientRecords, _ := setupActivityRecordsInStorage(t, time.Now().UTC(), true, false) + a, expectedClientRecords, _ := setupActivityRecordsInStorage(t, time.Now().UTC(), true, false, false) a.SetEnable(true) var wg sync.WaitGroup @@ -2082,13 +2195,13 @@ func TestActivityLog_refreshFromStoredLogNoTokens(t *testing.T) { } currentGlobalEntities := a.GetCurrentGlobalEntities() - if !entityRecordsEqual(t, currentGlobalEntities.Clients, expectedCurrentGlobal.Clients) { + if !EntityRecordsEqual(t, currentGlobalEntities.Clients, expectedCurrentGlobal.Clients) { // we only expect the newest entity segment to be loaded (for the current month) t.Errorf("bad activity entity logs loaded. expected: %v got: %v", expectedCurrentGlobal, currentGlobalEntities) } currentLocalEntities := a.GetCurrentLocalEntities() - if !entityRecordsEqual(t, currentLocalEntities.Clients, expectedCurrentLocal.Clients) { + if !EntityRecordsEqual(t, currentLocalEntities.Clients, expectedCurrentLocal.Clients) { // we only expect the newest local entity segment to be loaded (for the current month) t.Errorf("bad activity entity logs loaded. expected: %v got: %v", expectedCurrentLocal, currentLocalEntities) } @@ -2108,7 +2221,7 @@ func TestActivityLog_refreshFromStoredLogNoTokens(t *testing.T) { // TestActivityLog_refreshFromStoredLogNoEntities writes only direct tokens from 3 months ago to today, and runs // refreshFromStoredLog. It verifies that there are no entities or clients loaded. func TestActivityLog_refreshFromStoredLogNoEntities(t *testing.T) { - a, _, expectedTokenCounts := setupActivityRecordsInStorage(t, time.Now().UTC(), false, true) + a, _, expectedTokenCounts := setupActivityRecordsInStorage(t, time.Now().UTC(), false, true, false) a.SetEnable(true) var wg sync.WaitGroup @@ -2138,17 +2251,29 @@ func TestActivityLog_refreshFromStoredLogNoEntities(t *testing.T) { // current segment counts are zero. func TestActivityLog_refreshFromStoredLogNoData(t *testing.T) { now := time.Now().UTC() - a, _, _ := setupActivityRecordsInStorage(t, now, false, false) + a, _, _ := setupActivityRecordsInStorage(t, now, false, false, true) a.SetEnable(true) + // Simulate an upgrade that is incomplete + a.ResetDedupUpgrade(context.Background()) var wg sync.WaitGroup err := a.refreshFromStoredLog(context.Background(), &wg, now) if err != nil { t.Fatalf("got error loading stored activity logs: %v", err) } wg.Wait() + a.ExpectOldSegmentRefreshed(t, timeutil.StartOfMonth(now).Unix(), false, []*activity.EntityRecord{}, map[string]uint64{}) + a.ExpectCurrentSegmentsRefreshed(t, timeutil.StartOfMonth(now).Unix(), false) - a.ExpectCurrentSegmentRefreshed(t, now.Unix(), false) + // Simulate an upgrade that is complete + require.NoError(t, a.writeDedupClientsUpgrade(context.Background())) + err = a.refreshFromStoredLog(context.Background(), &wg, now) + if err != nil { + t.Fatalf("got error loading stored activity logs: %v", err) + } + wg.Wait() + a.ExpectOldSegmentRefreshed(t, timeutil.StartOfMonth(now).Unix(), false, []*activity.EntityRecord{}, map[string]uint64{}) + a.ExpectCurrentSegmentsRefreshed(t, timeutil.StartOfMonth(now).Unix(), false) } // TestActivityLog_refreshFromStoredLogTwoMonthsPrevious creates segment data from 5 months ago to 2 months ago and @@ -2157,17 +2282,29 @@ func TestActivityLog_refreshFromStoredLogTwoMonthsPrevious(t *testing.T) { // test what happens when the most recent data is from month M-2 (or earlier - same effect) now := time.Now().UTC() twoMonthsAgoStart := timeutil.StartOfPreviousMonth(timeutil.StartOfPreviousMonth(now)) - a, _, _ := setupActivityRecordsInStorage(t, twoMonthsAgoStart, true, true) + a, _, _ := setupActivityRecordsInStorage(t, twoMonthsAgoStart, true, true, true) a.SetEnable(true) + // Simulate an upgrade that is incomplete + a.ResetDedupUpgrade(context.Background()) var wg sync.WaitGroup err := a.refreshFromStoredLog(context.Background(), &wg, now) if err != nil { t.Fatalf("got error loading stored activity logs: %v", err) } wg.Wait() + a.ExpectCurrentSegmentsRefreshed(t, timeutil.StartOfMonth(now).Unix(), false) + a.ExpectOldSegmentRefreshed(t, timeutil.StartOfMonth(now).Unix(), false, []*activity.EntityRecord{}, map[string]uint64{}) - a.ExpectCurrentSegmentRefreshed(t, now.Unix(), false) + // Simulate an upgrade that is complete + a.writeDedupClientsUpgrade(context.Background()) + err = a.refreshFromStoredLog(context.Background(), &wg, now) + if err != nil { + t.Fatalf("got error loading stored activity logs: %v", err) + } + wg.Wait() + a.ExpectCurrentSegmentsRefreshed(t, timeutil.StartOfMonth(now).Unix(), false) + a.ExpectOldSegmentRefreshed(t, timeutil.StartOfMonth(now).Unix(), false, []*activity.EntityRecord{}, map[string]uint64{}) } // TestActivityLog_refreshFromStoredLogPreviousMonth creates segment data from 4 months ago to 1 month ago, then calls @@ -2178,9 +2315,12 @@ func TestActivityLog_refreshFromStoredLogPreviousMonth(t *testing.T) { // can handle end of month rotations monthStart := timeutil.StartOfMonth(time.Now().UTC()) oneMonthAgoStart := timeutil.StartOfPreviousMonth(monthStart) - a, expectedClientRecords, expectedTokenCounts := setupActivityRecordsInStorage(t, oneMonthAgoStart, true, true) + a, expectedClientRecords, expectedTokenCounts := setupActivityRecordsInStorage(t, oneMonthAgoStart, true, true, true) a.SetEnable(true) + // Reset upgrade attributes to simulate startup + a.ResetDedupUpgrade(context.Background()) + var wg sync.WaitGroup err := a.refreshFromStoredLog(context.Background(), &wg, time.Now().UTC()) if err != nil { @@ -2188,6 +2328,18 @@ func TestActivityLog_refreshFromStoredLogPreviousMonth(t *testing.T) { } wg.Wait() + // Previous month data should not be loaded into the currentSegment + a.ExpectOldSegmentRefreshed(t, monthStart.Unix(), false, []*activity.EntityRecord{}, map[string]uint64{}) + a.ExpectCurrentSegmentsRefreshed(t, monthStart.Unix(), false) + + // Simulate completion of upgrade + require.NoError(t, a.writeDedupClientsUpgrade(context.Background())) + + // With a refresh after upgrade is complete, the currentGlobalSegment and currentLocalSegment should contain data + err = a.refreshFromStoredLog(context.Background(), &wg, time.Now().UTC()) + require.NoError(t, err) + wg.Wait() + expectedActive := &activity.EntityActivityLog{ Clients: expectedClientRecords[1:], } @@ -2196,16 +2348,13 @@ func TestActivityLog_refreshFromStoredLogPreviousMonth(t *testing.T) { } currentEntities := a.GetCurrentGlobalEntities() - if !entityRecordsEqual(t, currentEntities.Clients, expectedCurrent.Clients) { + if !EntityRecordsEqual(t, currentEntities.Clients, expectedCurrent.Clients) { // we only expect the newest entity segment to be loaded (for the current month) t.Errorf("bad activity entity logs loaded. expected: %v got: %v", expectedCurrent, currentEntities) } nsCount := a.GetStoredTokenCountByNamespaceID() - if !reflect.DeepEqual(nsCount, expectedTokenCounts) { - // we expect all token counts to be loaded - t.Errorf("bad activity token counts loaded. expected: %v got: %v", expectedTokenCounts, nsCount) - } + require.Equal(t, expectedTokenCounts, nsCount) activeClients := a.core.GetActiveClientsList() if err := ActiveEntitiesEqual(activeClients, expectedActive.Clients); err != nil { @@ -2433,7 +2582,7 @@ func TestActivityLog_EnableDisable(t *testing.T) { } expectMissingSegment(t, core, path) - a.ExpectCurrentSegmentRefreshed(t, 0, false) + a.ExpectCurrentSegmentsRefreshed(t, 0, false) // enable (if not already) which force-writes an empty segment enableRequest() @@ -4152,7 +4301,7 @@ func TestActivityLog_partialMonthClientCount(t *testing.T) { ctx := namespace.RootContext(nil) now := time.Now().UTC() - a, clients, _ := setupActivityRecordsInStorage(t, timeutil.StartOfMonth(now), true, true) + a, clients, _ := setupActivityRecordsInStorage(t, timeutil.StartOfMonth(now), true, true, false) // clients[0] belongs to previous month clients = clients[1:] @@ -4223,7 +4372,7 @@ func TestActivityLog_partialMonthClientCountUsingHandleQuery(t *testing.T) { ctx := namespace.RootContext(nil) now := time.Now().UTC() - a, clients, _ := setupActivityRecordsInStorage(t, timeutil.StartOfMonth(now), true, true) + a, clients, _ := setupActivityRecordsInStorage(t, timeutil.StartOfMonth(now), true, true, false) // clients[0] belongs to previous month clients = clients[1:] @@ -5831,7 +5980,7 @@ func TestActivityLog_PrimaryDuplicateClientMigrationWorker(t *testing.T) { a.SetEnable(true) ctx := context.Background() - timeStamp := time.Now() + timeStamp := time.Now().UTC() startOfMonth := timeutil.StartOfMonth(timeStamp) oneMonthAgo := timeutil.StartOfPreviousMonth(timeStamp) twoMonthsAgo := timeutil.StartOfPreviousMonth(oneMonthAgo) @@ -5865,13 +6014,28 @@ func TestActivityLog_PrimaryDuplicateClientMigrationWorker(t *testing.T) { a.savePreviousEntitySegments(ctx, oneMonthAgo.Unix(), "", []*activity.LogFragment{{Clients: append(clientRecordsLocal[1:], clientRecordsGlobal[1:]...)}}) a.savePreviousEntitySegments(ctx, startOfMonth.Unix(), "", []*activity.LogFragment{{Clients: append(clientRecordsLocal[2:], clientRecordsGlobal[2:]...)}}) + // Write tokens to old path. We write twice to simulate multiple segments for each month + for i := 0; i < 2; i++ { + writeTokenSegmentOldPath(t, core, twoMonthsAgo, i, &activity.TokenCount{CountByNamespaceID: tokenCounts}) + writeTokenSegmentOldPath(t, core, oneMonthAgo, i, &activity.TokenCount{CountByNamespaceID: tokenCounts}) + writeTokenSegmentOldPath(t, core, startOfMonth, i, &activity.TokenCount{CountByNamespaceID: tokenCounts}) + } + + // Write secondary cluster data. This is to make sure that the data at these paths are garbage collected at the end of the migration routine + numSecondarySegments := 4 + secondaryIds := make([]string, 0) + for i := 0; i < numSecondarySegments; i++ { + writeSecondaryClusterSegment(t, core, twoMonthsAgo, i, fmt.Sprintf("cluster_%d", i), &activity.EntityActivityLog{Clients: clientRecordsGlobal[:ActivitySegmentClientCapacity]}) + writeSecondaryClusterSegment(t, core, oneMonthAgo, i, fmt.Sprintf("cluster_%d", i), &activity.EntityActivityLog{Clients: clientRecordsGlobal[1:ActivitySegmentClientCapacity]}) + writeSecondaryClusterSegment(t, core, startOfMonth, i, fmt.Sprintf("cluster_%d", i), &activity.EntityActivityLog{Clients: clientRecordsGlobal[2:ActivitySegmentClientCapacity]}) + secondaryIds = append(secondaryIds, fmt.Sprintf("cluster_%d", i)) + } + // Assert that the migration workers have not been run require.True(t, a.hasDedupClientsUpgrade(ctx)) - require.True(t, a.dedupClientsUpgradeComplete.Load()) // Resetting this to false so that we can // verify that after the migrations is completed, the correct values have been stored - a.dedupClientsUpgradeComplete.Store(false) require.NoError(t, a.view.Delete(ctx, activityDeduplicationUpgradeKey)) // Forcefully run the primary migration worker @@ -5891,6 +6055,7 @@ func TestActivityLog_PrimaryDuplicateClientMigrationWorker(t *testing.T) { require.NoError(t, err) globalClients = append(globalClients, segment.GetClients()...) } + // We've added duplicate clients from secondaries, so this should not affect the count of the global clients require.Equal(t, len(clientRecordsGlobal)-index, len(globalClients)) } @@ -5914,31 +6079,23 @@ func TestActivityLog_PrimaryDuplicateClientMigrationWorker(t *testing.T) { for _, time := range times { reader, err := a.NewSegmentFileReader(ctx, time) require.NoError(t, err) + numTokenSegments := 0 for { segment, err := reader.ReadToken(ctx) if errors.Is(err, io.EOF) { break } + numTokenSegments += 1 require.NoError(t, err) // Verify that the data is correct deep.Equal(segment.GetCountByNamespaceID(), tokenCounts) } + // All tokens should have been combined into one segment + require.Equal(t, 1, numTokenSegments) } // Check that the storage key has been updated require.True(t, a.hasDedupClientsUpgrade(ctx)) - // Check that the bool has been updated - require.True(t, a.dedupClientsUpgradeComplete.Load()) - - // Wait for the deletion of old logs to complete - timeout := time.After(25 * time.Second) - // Wait for channel indicating deletion to be written - select { - case <-timeout: - t.Fatal("timed out waiting for deletion to complete") - case <-a.oldStoragePathsCleaned: - break - } // Verify there is no data at the old paths times, err := a.availableTimesAtPath(ctx, time.Now(), activityEntityBasePath) @@ -5949,152 +6106,11 @@ func TestActivityLog_PrimaryDuplicateClientMigrationWorker(t *testing.T) { times, err = a.availableTimesAtPath(ctx, time.Now(), activityTokenBasePath) require.NoError(t, err) require.Equal(t, 0, len(times)) -} - -// TestActivityLog_SecondaryDuplicateClientMigrationWorker verifies that the secondary -// migration worker correctly moves local data from old location to the new location -func TestActivityLog_SecondaryDuplicateClientMigrationWorker(t *testing.T) { - cluster := NewTestCluster(t, nil, nil) - core := cluster.Cores[0].Core - a := core.activityLog - a.SetEnable(true) - - ctx := context.Background() - timeStamp := time.Now() - startOfMonth := timeutil.StartOfMonth(timeStamp) - oneMonthAgo := timeutil.StartOfPreviousMonth(timeStamp) - twoMonthsAgo := timeutil.StartOfPreviousMonth(oneMonthAgo) - - clientRecordsGlobal := make([]*activity.EntityRecord, ActivitySegmentClientCapacity*2+1) - for i := range clientRecordsGlobal { - clientRecordsGlobal[i] = &activity.EntityRecord{ - ClientID: fmt.Sprintf("111122222-3333-4444-5555-%012v", i), - Timestamp: timeStamp.Unix(), - NonEntity: false, - } - } - clientRecordsLocal := make([]*activity.EntityRecord, ActivitySegmentClientCapacity*2+1) - for i := range clientRecordsGlobal { - clientRecordsLocal[i] = &activity.EntityRecord{ - ClientID: fmt.Sprintf("011122222-3333-4444-5555-%012v", i), - Timestamp: timeStamp.Unix(), - // This is to trick the system into believing this a local client when parsing data - ClientType: nonEntityTokenActivityType, - } - } - - tokenCounts := map[string]uint64{ - "ns1": 10, - "ns2": 11, - "ns3": 12, - } - - // Write global and local clients to old path - a.savePreviousEntitySegments(ctx, twoMonthsAgo.Unix(), "", []*activity.LogFragment{{Clients: append(clientRecordsLocal, clientRecordsGlobal...)}}) - a.savePreviousEntitySegments(ctx, oneMonthAgo.Unix(), "", []*activity.LogFragment{{Clients: append(clientRecordsLocal[1:], clientRecordsGlobal[1:]...)}}) - a.savePreviousEntitySegments(ctx, startOfMonth.Unix(), "", []*activity.LogFragment{{Clients: append(clientRecordsLocal[2:], clientRecordsGlobal[2:]...)}}) - // Write tokens to old path - a.savePreviousTokenSegments(ctx, twoMonthsAgo.Unix(), "", []*activity.LogFragment{{NonEntityTokens: tokenCounts}}) - a.savePreviousTokenSegments(ctx, oneMonthAgo.Unix(), "", []*activity.LogFragment{{NonEntityTokens: tokenCounts}}) - a.savePreviousTokenSegments(ctx, startOfMonth.Unix(), "", []*activity.LogFragment{{NonEntityTokens: tokenCounts}}) - - // Assert that the migration workers have not been run - require.True(t, a.hasDedupClientsUpgrade(ctx)) - require.True(t, a.dedupClientsUpgradeComplete.Load()) - - // Resetting this to false so that we can - // verify that after the migrations is completed, the correct values have been stored - a.dedupClientsUpgradeComplete.Store(false) - require.NoError(t, a.view.Delete(ctx, activityDeduplicationUpgradeKey)) - - // Forcefully run the secondary migration worker - core.secondaryDuplicateClientMigrationWorker(ctx) - - // Wait for the storage migration to complete - ticker := time.NewTicker(100 * time.Millisecond) - timeout := time.After(25 * time.Second) - for { - select { - case <-timeout: - t.Fatal("timed out waiting for migration to complete") - case <-ticker.C: - } - if a.dedupClientsUpgradeComplete.Load() { - break - } - } - - // Verify that no global clients have been migrated - times := []time.Time{twoMonthsAgo, oneMonthAgo, startOfMonth} - for _, time := range times { - reader, err := a.NewSegmentFileReader(ctx, time) + // Verify there is no data at the secondary cluster paths + for _, secondaryId := range secondaryIds { + times, err = a.availableTimesAtPath(ctx, time.Now(), activitySecondaryTempDataPathPrefix+secondaryId+activityEntityBasePath) require.NoError(t, err) - globalClients := make([]*activity.EntityRecord, 0) - for { - segment, err := reader.ReadGlobalEntity(ctx) - if errors.Is(err, io.EOF) { - break - } - require.NoError(t, err) - globalClients = append(globalClients, segment.GetClients()...) - } - require.Equal(t, 0, len(globalClients)) + require.Equal(t, 0, len(times)) } - - // Verify local clients have been correctly migrated - for index, time := range times { - reader, err := a.NewSegmentFileReader(ctx, time) - require.NoError(t, err) - localClients := make([]*activity.EntityRecord, 0) - for { - segment, err := reader.ReadLocalEntity(ctx) - if errors.Is(err, io.EOF) { - break - } - require.NoError(t, err) - localClients = append(localClients, segment.GetClients()...) - } - require.Equal(t, len(clientRecordsLocal)-index, len(localClients)) - } - - // Verify non-entity tokens have been correctly migrated - for _, time := range times { - reader, err := a.NewSegmentFileReader(ctx, time) - require.NoError(t, err) - for { - segment, err := reader.ReadToken(ctx) - if errors.Is(err, io.EOF) { - break - } - require.NoError(t, err) - // Verify that the data is correct - deep.Equal(segment.GetCountByNamespaceID(), tokenCounts) - } - } - - // Check that the storage key has been updated - require.True(t, a.hasDedupClientsUpgrade(ctx)) - // Check that the bool has been updated - require.True(t, a.dedupClientsUpgradeComplete.Load()) - - // Wait for the deletion of old logs to complete - timeout = time.After(25 * time.Second) - // Wait for channel indicating deletion to be written - select { - case <-timeout: - t.Fatal("timed out waiting for deletion to complete") - case <-a.oldStoragePathsCleaned: - break - } - - // Verify there is no data at the old entity paths - times, err := a.availableTimesAtPath(ctx, time.Now(), activityEntityBasePath) - require.NoError(t, err) - require.Equal(t, 0, len(times)) - - // Verify there is no data at the old token paths - times, err = a.availableTimesAtPath(ctx, time.Now(), activityTokenBasePath) - require.NoError(t, err) - require.Equal(t, 0, len(times)) } diff --git a/vault/activity_log_testing_util.go b/vault/activity_log_testing_util.go index d0fd4b7b35ae..42566bb9201e 100644 --- a/vault/activity_log_testing_util.go +++ b/vault/activity_log_testing_util.go @@ -7,13 +7,18 @@ import ( "context" "fmt" "math/rand" + "sort" + "strings" + "sync" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/vault/helper/constants" "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/vault/activity" + "github.com/stretchr/testify/require" "google.golang.org/protobuf/testing/protocmp" ) @@ -187,74 +192,134 @@ func RandStringBytes(n int) string { return string(b) } -// ExpectCurrentSegmentRefreshed verifies that the current segment has been refreshed -// non-nil empty components and updated with the `expectedStart` timestamp +// ExpectOldSegmentRefreshed verifies that the old current segment structure has been refreshed +// non-nil empty components and updated with the `expectedStart` timestamp. This is expected when +// an upgrade has not yet completed. // Note: if `verifyTimeNotZero` is true, ignore `expectedStart` and just make sure the timestamp isn't 0 -func (a *ActivityLog) ExpectCurrentSegmentRefreshed(t *testing.T, expectedStart int64, verifyTimeNotZero bool) { +func (a *ActivityLog) ExpectOldSegmentRefreshed(t *testing.T, expectedStart int64, verifyTimeNotZero bool, expectedEntities []*activity.EntityRecord, directTokens map[string]uint64) { t.Helper() a.l.RLock() defer a.l.RUnlock() a.fragmentLock.RLock() defer a.fragmentLock.RUnlock() - if a.currentGlobalSegment.currentClients == nil { - t.Fatalf("expected non-nil currentSegment.currentClients") - } - if a.currentGlobalSegment.currentClients.Clients == nil { - t.Errorf("expected non-nil currentSegment.currentClients.Entities") - } - if a.currentGlobalSegment.tokenCount == nil { - t.Fatalf("expected non-nil currentSegment.tokenCount") - } - if a.currentGlobalSegment.tokenCount.CountByNamespaceID == nil { - t.Errorf("expected non-nil currentSegment.tokenCount.CountByNamespaceID") - } - if a.currentLocalSegment.currentClients == nil { - t.Fatalf("expected non-nil currentSegment.currentClients") - } - if a.currentLocalSegment.currentClients.Clients == nil { - t.Errorf("expected non-nil currentSegment.currentClients.Entities") - } - if a.currentLocalSegment.tokenCount == nil { - t.Fatalf("expected non-nil currentSegment.tokenCount") - } - if a.currentLocalSegment.tokenCount.CountByNamespaceID == nil { - t.Errorf("expected non-nil currentSegment.tokenCount.CountByNamespaceID") + require.NotNil(t, a.currentSegment.currentClients) + require.NotNil(t, a.currentSegment.currentClients.Clients) + require.NotNil(t, a.currentSegment.tokenCount) + require.NotNil(t, a.currentSegment.tokenCount.CountByNamespaceID) + if !EntityRecordsEqual(t, a.currentSegment.currentClients.Clients, expectedEntities) { + // we only expect the newest entity segment to be loaded (for the current month) + t.Errorf("bad activity entity logs loaded. expected: %v got: %v", a.currentSegment.currentClients.Clients, expectedEntities) } - if a.partialMonthLocalClientTracker == nil { - t.Errorf("expected non-nil partialMonthLocalClientTracker") - } - if a.globalPartialMonthClientTracker == nil { - t.Errorf("expected non-nil globalPartialMonthClientTracker") - } - if len(a.currentGlobalSegment.currentClients.Clients) > 0 { - t.Errorf("expected no current entity segment to be loaded. got: %v", a.currentGlobalSegment.currentClients) + require.Equal(t, directTokens, a.currentSegment.tokenCount.CountByNamespaceID) + if verifyTimeNotZero { + require.NotEqual(t, a.currentSegment.startTimestamp, 0) + } else { + require.Equal(t, a.currentSegment.startTimestamp, expectedStart) } - if len(a.currentLocalSegment.currentClients.Clients) > 0 { - t.Errorf("expected no current entity segment to be loaded. got: %v", a.currentLocalSegment.currentClients) +} + +// ExpectCurrentSegmentsRefreshed verifies that the current segment has been refreshed +// non-nil empty components and updated with the `expectedStart` timestamp +// Note: if `verifyTimeNotZero` is true, ignore `expectedStart` and just make sure the timestamp isn't 0 +func (a *ActivityLog) ExpectCurrentSegmentsRefreshed(t *testing.T, expectedStart int64, verifyTimeNotZero bool) { + t.Helper() + + a.l.RLock() + defer a.l.RUnlock() + a.fragmentLock.RLock() + defer a.fragmentLock.RUnlock() + require.NotNil(t, a.currentGlobalSegment.currentClients) + require.NotNil(t, a.currentGlobalSegment.currentClients.Clients) + require.NotNil(t, a.currentGlobalSegment.tokenCount) + require.NotNil(t, a.currentGlobalSegment.tokenCount.CountByNamespaceID) + + require.NotNil(t, a.currentLocalSegment.currentClients) + require.NotNil(t, a.currentLocalSegment.currentClients.Clients) + require.NotNil(t, a.currentLocalSegment.tokenCount) + require.NotNil(t, a.currentLocalSegment.tokenCount.CountByNamespaceID) + + require.NotNil(t, a.partialMonthLocalClientTracker) + require.NotNil(t, a.globalPartialMonthClientTracker) + + require.Equal(t, 0, len(a.currentGlobalSegment.currentClients.Clients)) + require.Equal(t, 0, len(a.currentLocalSegment.currentClients.Clients)) + require.Equal(t, 0, len(a.currentLocalSegment.tokenCount.CountByNamespaceID)) + + require.Equal(t, 0, len(a.partialMonthLocalClientTracker)) + require.Equal(t, 0, len(a.globalPartialMonthClientTracker)) + + if verifyTimeNotZero { + require.NotEqual(t, 0, a.currentGlobalSegment.startTimestamp) + require.NotEqual(t, 0, a.currentLocalSegment.startTimestamp) + require.NotEqual(t, 0, a.currentSegment.startTimestamp) + } else { + require.Equal(t, expectedStart, a.currentGlobalSegment.startTimestamp) + require.Equal(t, expectedStart, a.currentLocalSegment.startTimestamp) } - if len(a.currentLocalSegment.tokenCount.CountByNamespaceID) > 0 { - t.Errorf("expected no token counts to be loaded. got: %v", a.currentLocalSegment.tokenCount.CountByNamespaceID) +} + +// EntityRecordsEqual compares the parts we care about from two activity entity record slices +// note: this makes a copy of the []*activity.EntityRecord so that misordered slices won't fail the comparison, +// but the function won't modify the order of the slices to compare +func EntityRecordsEqual(t *testing.T, record1, record2 []*activity.EntityRecord) bool { + t.Helper() + + if record1 == nil { + return record2 == nil } - if len(a.partialMonthLocalClientTracker) > 0 { - t.Errorf("expected no active entity segment to be loaded. got: %v", a.partialMonthLocalClientTracker) + if record2 == nil { + return record1 == nil } - if len(a.globalPartialMonthClientTracker) > 0 { - t.Errorf("expected no active entity segment to be loaded. got: %v", a.globalPartialMonthClientTracker) + + if len(record1) != len(record2) { + return false } - if verifyTimeNotZero { - if a.currentGlobalSegment.startTimestamp == 0 { - t.Error("bad start timestamp. expected no reset but timestamp was reset") + // sort first on namespace, then on ID, then on timestamp + entityLessFn := func(e []*activity.EntityRecord, i, j int) bool { + ei := e[i] + ej := e[j] + + nsComp := strings.Compare(ei.NamespaceID, ej.NamespaceID) + if nsComp == -1 { + return true } - if a.currentLocalSegment.startTimestamp == 0 { - t.Error("bad start timestamp. expected no reset but timestamp was reset") + if nsComp == 1 { + return false + } + + idComp := strings.Compare(ei.ClientID, ej.ClientID) + if idComp == -1 { + return true + } + if idComp == 1 { + return false + } + + return ei.Timestamp < ej.Timestamp + } + + entitiesCopy1 := make([]*activity.EntityRecord, len(record1)) + entitiesCopy2 := make([]*activity.EntityRecord, len(record2)) + copy(entitiesCopy1, record1) + copy(entitiesCopy2, record2) + + sort.Slice(entitiesCopy1, func(i, j int) bool { + return entityLessFn(entitiesCopy1, i, j) + }) + sort.Slice(entitiesCopy2, func(i, j int) bool { + return entityLessFn(entitiesCopy2, i, j) + }) + + for i, a := range entitiesCopy1 { + b := entitiesCopy2[i] + if a.ClientID != b.ClientID || a.NamespaceID != b.NamespaceID || a.Timestamp != b.Timestamp { + return false } - } else if a.currentGlobalSegment.startTimestamp != expectedStart { - t.Errorf("bad start timestamp. expected: %v got: %v", expectedStart, a.currentGlobalSegment.startTimestamp) - } else if a.currentLocalSegment.startTimestamp != expectedStart { - t.Errorf("bad start timestamp. expected: %v got: %v", expectedStart, a.currentLocalSegment.startTimestamp) } + + return true } // ActiveEntitiesEqual checks that only the set of `test` exists in `active` @@ -284,6 +349,7 @@ func (a *ActivityLog) SetStartTimestamp(timestamp int64) { defer a.l.Unlock() a.currentGlobalSegment.startTimestamp = timestamp a.currentLocalSegment.startTimestamp = timestamp + a.currentSegment.startTimestamp = timestamp } // GetStoredTokenCountByNamespaceID returns the count of tokens by namespace ID @@ -370,3 +436,38 @@ func (c *Core) DeleteLogsAtPath(ctx context.Context, t *testing.T, storagePath s } } } + +// SaveEntitySegment is a test helper function to keep the savePreviousEntitySegments function internal +func (a *ActivityLog) SaveEntitySegment(ctx context.Context, startTime int64, pathPrefix string, fragments []*activity.LogFragment) error { + return a.savePreviousEntitySegments(ctx, startTime, pathPrefix, fragments) +} + +// LaunchMigrationWorker is a test only helper function that launches the migration workers. +// This allows us to keep the migration worker methods internal +func (a *ActivityLog) LaunchMigrationWorker(ctx context.Context, isSecondary bool) { + if isSecondary { + go a.core.secondaryDuplicateClientMigrationWorker(ctx) + } else { + go a.core.primaryDuplicateClientMigrationWorker(ctx) + } +} + +// DedupUpgradeComplete is a test helper function that indicates whether the +// all correct states have been set after completing upgrade processes to 1.19+ +func (a *ActivityLog) DedupUpgradeComplete(ctx context.Context) bool { + return a.hasDedupClientsUpgrade(ctx) +} + +// ResetDedupUpgrade is a test helper function that resets the state to reflect +// how the system should look before running/completing any upgrade process to 1.19+ +func (a *ActivityLog) ResetDedupUpgrade(ctx context.Context) { + a.view.Delete(ctx, activityDeduplicationUpgradeKey) + a.view.Delete(ctx, activitySecondaryDataRecCount) +} + +// RefreshActivityLog is a test helper functions that refreshes the activity logs +// segments and current month data. This allows us to keep the refreshFromStoredLog +// function internal +func (a *ActivityLog) RefreshActivityLog(ctx context.Context) { + a.refreshFromStoredLog(ctx, &sync.WaitGroup{}, time.Now().UTC()) +} diff --git a/vault/activity_log_util.go b/vault/activity_log_util.go index 890af5533fad..8c2585717c92 100644 --- a/vault/activity_log_util.go +++ b/vault/activity_log_util.go @@ -7,9 +7,21 @@ package vault import ( "context" + + "github.com/hashicorp/vault/vault/activity" ) // sendCurrentFragment is a no-op on OSS func (a *ActivityLog) sendCurrentFragment(ctx context.Context) error { return nil } + +// receiveSecondaryPreviousMonthGlobalData is a no-op on OSS +func (a *ActivityLog) receiveSecondaryPreviousMonthGlobalData(ctx context.Context, month int64, clients *activity.LogFragment) error { + return nil +} + +// sendPreviousMonthGlobalClientsWorker is a no-op on OSS +func (a *ActivityLog) sendPreviousMonthGlobalClientsWorker(ctx context.Context) (map[int64][]*activity.EntityRecord, error) { + return map[int64][]*activity.EntityRecord{}, nil +} diff --git a/vault/activity_log_util_common.go b/vault/activity_log_util_common.go index 86c824adebab..8b1bea9bcbb0 100644 --- a/vault/activity_log_util_common.go +++ b/vault/activity_log_util_common.go @@ -10,6 +10,7 @@ import ( "io" "slices" "sort" + "strconv" "strings" "time" @@ -565,32 +566,25 @@ func (a *ActivityLog) extractLocalGlobalClientsDeprecatedStoragePath(ctx context return clusterLocalClients, clusterGlobalClients, fmt.Errorf("could not list available logs on the cluster") } for _, time := range times { - entityPath := activityEntityBasePath + fmt.Sprint(time.Unix()) + "/" - segmentPaths, err := a.view.List(ctx, entityPath) + segments, err := a.getAllEntitySegmentsForMonth(ctx, activityEntityBasePath, time.Unix()) if err != nil { return nil, nil, err } - for _, seqNumber := range segmentPaths { - segment, err := a.readEntitySegmentAtPath(ctx, entityPath+seqNumber) - if segment == nil { - continue - } - if err != nil { - a.logger.Warn("failed to read segment", "error", err) - return clusterLocalClients, clusterGlobalClients, err - } + for _, segment := range segments { for _, entity := range segment.GetClients() { // If the client is not local, then add it to a map + // Normalize month value to the beginning of the month to avoid multiple storage entries for the same month + startOfMonth := timeutil.StartOfMonth(time.UTC()) if local, _ := a.isClientLocal(entity); !local { - if _, ok := clusterGlobalClients[time.Unix()]; !ok { - clusterGlobalClients[time.Unix()] = make([]*activity.EntityRecord, 0) + if _, ok := clusterGlobalClients[startOfMonth.Unix()]; !ok { + clusterGlobalClients[startOfMonth.Unix()] = make([]*activity.EntityRecord, 0) } - clusterGlobalClients[time.Unix()] = append(clusterGlobalClients[time.Unix()], entity) + clusterGlobalClients[startOfMonth.Unix()] = append(clusterGlobalClients[startOfMonth.Unix()], entity) } else { - if _, ok := clusterLocalClients[time.Unix()]; !ok { - clusterLocalClients[time.Unix()] = make([]*activity.EntityRecord, 0) + if _, ok := clusterLocalClients[startOfMonth.Unix()]; !ok { + clusterLocalClients[startOfMonth.Unix()] = make([]*activity.EntityRecord, 0) } - clusterLocalClients[time.Unix()] = append(clusterLocalClients[time.Unix()], entity) + clusterLocalClients[startOfMonth.Unix()] = append(clusterLocalClients[startOfMonth.Unix()], entity) } } } @@ -627,6 +621,25 @@ func (a *ActivityLog) extractTokensDeprecatedStoragePath(ctx context.Context) (m return tokensByMonth, nil } +func (a *ActivityLog) getAllEntitySegmentsForMonth(ctx context.Context, path string, time int64) ([]*activity.EntityActivityLog, error) { + entityPathWithTime := fmt.Sprintf("%s%d/", path, time) + segments := make([]*activity.EntityActivityLog, 0) + segmentPaths, err := a.view.List(ctx, entityPathWithTime) + if err != nil { + return segments, err + } + for _, seqNum := range segmentPaths { + segment, err := a.readEntitySegmentAtPath(ctx, entityPathWithTime+seqNum) + if err != nil { + return segments, err + } + if segment != nil { + segments = append(segments, segment) + } + } + return segments, nil +} + // OldestVersionHasDeduplicatedClients returns whether this cluster is 1.19+, and // hence supports deduplicated clients func (a *ActivityLog) OldestVersionHasDeduplicatedClients(ctx context.Context) bool { @@ -648,3 +661,25 @@ func (a *ActivityLog) OldestVersionHasDeduplicatedClients(ctx context.Context) b } return oldestVersionIsDedupClients } + +func (a *ActivityLog) loadClientDataIntoSegment(ctx context.Context, pathPrefix string, startTime time.Time, seqNum uint64, currentSegment *segmentInfo) ([]*activity.EntityRecord, error) { + path := pathPrefix + activityEntityBasePath + fmt.Sprint(startTime.Unix()) + "/" + strconv.FormatUint(seqNum, 10) + out, err := a.readEntitySegmentAtPath(ctx, path) + if err != nil && !errors.Is(err, ErrEmptyResponse) { + return nil, err + } + if out != nil { + if !a.core.perfStandby { + a.logger.Debug(fmt.Sprintf("loading client data from %s into segment", path)) + currentSegment.startTimestamp = startTime.Unix() + currentSegment.currentClients = &activity.EntityActivityLog{Clients: out.Clients} + currentSegment.clientSequenceNumber = seqNum + + } else { + // populate this for edge case checking (if end of month passes while background loading on standby) + currentSegment.startTimestamp = startTime.Unix() + } + return out.GetClients(), nil + } + return []*activity.EntityRecord{}, nil +} diff --git a/vault/activity_log_util_common_test.go b/vault/activity_log_util_common_test.go index 2d0a0c4ceee2..482589972447 100644 --- a/vault/activity_log_util_common_test.go +++ b/vault/activity_log_util_common_test.go @@ -991,6 +991,22 @@ func Test_ActivityLog_ComputeCurrentMonth_NamespaceMounts(t *testing.T) { } } +// writeOldEntityPathSegment writes a single segment to the old storage path with the given time and index for an entity +func writeOldEntityPathSegment(t *testing.T, core *Core, ts time.Time, index int, item *activity.EntityActivityLog) { + t.Helper() + protoItem, err := proto.Marshal(item) + require.NoError(t, err) + WriteToStorage(t, core, makeSegmentPath(t, activityEntityBasePath, ts, index), protoItem) +} + +// writeSecondaryClusterSegment writes a single secondary global segment file with the given time and index for an entity +func writeSecondaryClusterSegment(t *testing.T, core *Core, ts time.Time, index int, clusterId string, item *activity.EntityActivityLog) { + t.Helper() + protoItem, err := proto.Marshal(item) + require.NoError(t, err) + WriteToStorage(t, core, makeSegmentPath(t, fmt.Sprintf("%s%s/%s", activitySecondaryTempDataPathPrefix, clusterId, activityEntityBasePath), ts, index), protoItem) +} + // writeGlobalEntitySegment writes a single global segment file with the given time and index for an entity func writeGlobalEntitySegment(t *testing.T, core *Core, ts time.Time, index int, item *activity.EntityActivityLog) { t.Helper() From c27a54a99c99500923d7e817bd1a763ac3726062 Mon Sep 17 00:00:00 2001 From: helenfufu <25168806+helenfufu@users.noreply.github.com> Date: Tue, 10 Dec 2024 14:30:21 -0800 Subject: [PATCH 36/45] add vault build date to system view plugin env VAULT-32676 (#29082) --------- Co-authored-by: Thy Ton --- changelog/29082.txt | 3 ++ sdk/logical/plugin.pb.go | 59 ++++++++++++++++++++----------- sdk/logical/plugin.proto | 5 +++ vault/dynamic_system_view.go | 8 +++++ vault/dynamic_system_view_test.go | 46 ++++++++++++++++++++++++ vault/testing_util.go | 21 +++++------ vault/testing_util_stubs_oss.go | 21 +++++++++++ version/version.go | 9 +++++ 8 files changed, 142 insertions(+), 30 deletions(-) create mode 100644 changelog/29082.txt create mode 100644 vault/testing_util_stubs_oss.go diff --git a/changelog/29082.txt b/changelog/29082.txt new file mode 100644 index 000000000000..94e1ab78af77 --- /dev/null +++ b/changelog/29082.txt @@ -0,0 +1,3 @@ +```release-note:improvement +sdk: Add Vault build date to system view plugin environment response +``` diff --git a/sdk/logical/plugin.pb.go b/sdk/logical/plugin.pb.go index 3c6f951c9ed4..9da3a80e3f7b 100644 --- a/sdk/logical/plugin.pb.go +++ b/sdk/logical/plugin.pb.go @@ -12,6 +12,7 @@ package logical import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" ) @@ -34,6 +35,8 @@ type PluginEnvironment struct { VaultVersionPrerelease string `protobuf:"bytes,2,opt,name=vault_version_prerelease,json=vaultVersionPrerelease,proto3" json:"vault_version_prerelease,omitempty"` // VaultVersionMetadata is the version metadata of the Vault server VaultVersionMetadata string `protobuf:"bytes,3,opt,name=vault_version_metadata,json=vaultVersionMetadata,proto3" json:"vault_version_metadata,omitempty"` + // VaultBuildDate is the build date of the Vault server + VaultBuildDate *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=vault_build_date,json=vaultBuildDate,proto3" json:"vault_build_date,omitempty"` } func (x *PluginEnvironment) Reset() { @@ -87,25 +90,39 @@ func (x *PluginEnvironment) GetVaultVersionMetadata() string { return "" } +func (x *PluginEnvironment) GetVaultBuildDate() *timestamppb.Timestamp { + if x != nil { + return x.VaultBuildDate + } + return nil +} + var File_sdk_logical_plugin_proto protoreflect.FileDescriptor var file_sdk_logical_plugin_proto_rawDesc = []byte{ 0x0a, 0x18, 0x73, 0x64, 0x6b, 0x2f, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x61, 0x6c, 0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x07, 0x6c, 0x6f, 0x67, 0x69, - 0x63, 0x61, 0x6c, 0x22, 0xa8, 0x01, 0x0a, 0x11, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x45, 0x6e, - 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x76, 0x61, 0x75, - 0x6c, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0c, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x38, - 0x0a, 0x18, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x5f, - 0x70, 0x72, 0x65, 0x72, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x16, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x50, 0x72, - 0x65, 0x72, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x16, 0x76, 0x61, 0x75, 0x6c, - 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x42, 0x28, - 0x5a, 0x26, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, - 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x2f, 0x73, 0x64, 0x6b, - 0x2f, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x61, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x63, 0x61, 0x6c, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xee, 0x01, 0x0a, 0x11, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x45, + 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x76, 0x61, + 0x75, 0x6c, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0c, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x38, 0x0a, 0x18, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x5f, 0x70, 0x72, 0x65, 0x72, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x16, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x50, + 0x72, 0x65, 0x72, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x16, 0x76, 0x61, 0x75, + 0x6c, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x76, 0x61, 0x75, 0x6c, 0x74, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, + 0x44, 0x0a, 0x10, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x5f, 0x64, + 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0e, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x75, 0x69, 0x6c, + 0x64, 0x44, 0x61, 0x74, 0x65, 0x42, 0x28, 0x5a, 0x26, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x76, 0x61, + 0x75, 0x6c, 0x74, 0x2f, 0x73, 0x64, 0x6b, 0x2f, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x61, 0x6c, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -122,14 +139,16 @@ func file_sdk_logical_plugin_proto_rawDescGZIP() []byte { var file_sdk_logical_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_sdk_logical_plugin_proto_goTypes = []any{ - (*PluginEnvironment)(nil), // 0: logical.PluginEnvironment + (*PluginEnvironment)(nil), // 0: logical.PluginEnvironment + (*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp } var file_sdk_logical_plugin_proto_depIdxs = []int32{ - 0, // [0:0] is the sub-list for method output_type - 0, // [0:0] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name + 1, // 0: logical.PluginEnvironment.vault_build_date:type_name -> google.protobuf.Timestamp + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name } func init() { file_sdk_logical_plugin_proto_init() } diff --git a/sdk/logical/plugin.proto b/sdk/logical/plugin.proto index 5e19274ee2cb..e4365f7e256f 100644 --- a/sdk/logical/plugin.proto +++ b/sdk/logical/plugin.proto @@ -5,6 +5,8 @@ syntax = "proto3"; package logical; +import "google/protobuf/timestamp.proto"; + option go_package = "github.com/hashicorp/vault/sdk/logical"; message PluginEnvironment { @@ -16,4 +18,7 @@ message PluginEnvironment { // VaultVersionMetadata is the version metadata of the Vault server string vault_version_metadata = 3; + + // VaultBuildDate is the build date of the Vault server + google.protobuf.Timestamp vault_build_date = 4; } diff --git a/vault/dynamic_system_view.go b/vault/dynamic_system_view.go index f95dbd7ed963..3c161ec5abc8 100644 --- a/vault/dynamic_system_view.go +++ b/vault/dynamic_system_view.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/vault/plugincatalog" "github.com/hashicorp/vault/version" + "google.golang.org/protobuf/types/known/timestamppb" ) type ctxKeyForwardedRequestMountAccessor struct{} @@ -407,10 +408,17 @@ func (d dynamicSystemView) GroupsForEntity(entityID string) ([]*logical.Group, e func (d dynamicSystemView) PluginEnv(_ context.Context) (*logical.PluginEnvironment, error) { v := version.GetVersion() + + buildDate, err := version.GetVaultBuildDate() + if err != nil { + return nil, err + } + return &logical.PluginEnvironment{ VaultVersion: v.Version, VaultVersionPrerelease: v.VersionPrerelease, VaultVersionMetadata: v.VersionMetadata, + VaultBuildDate: timestamppb.New(buildDate), }, nil } diff --git a/vault/dynamic_system_view_test.go b/vault/dynamic_system_view_test.go index bf7d82e02d1c..a042ff424403 100644 --- a/vault/dynamic_system_view_test.go +++ b/vault/dynamic_system_view_test.go @@ -16,6 +16,8 @@ import ( "github.com/hashicorp/vault/helper/namespace" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/logical" + "github.com/hashicorp/vault/version" + "google.golang.org/protobuf/types/known/timestamppb" ) var ( @@ -286,6 +288,50 @@ func TestDynamicSystemView_GeneratePasswordFromPolicy_failed(t *testing.T) { } } +// TestDynamicSystemView_PluginEnv_successful checks that the PluginEnv method returns the expected values in a successful case. +func TestDynamicSystemView_PluginEnv_successful(t *testing.T) { + coreConfig := &CoreConfig{ + CredentialBackends: map[string]logical.Factory{}, + } + + cluster := NewTestCluster(t, coreConfig, &TestClusterOptions{}) + + cluster.Start() + defer cluster.Cleanup() + + core := cluster.Cores[0].Core + TestWaitActive(t, core) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + ctx = namespace.RootContext(ctx) + dsv := TestDynamicSystemView(cluster.Cores[0].Core, nil) + + pluginEnv, err := dsv.PluginEnv(ctx) + if err != nil { + t.Fatalf("no error expected, but got: %s", err) + } + + expectedVersionInfo := version.GetVersion() + + expectedBuildDate, err := version.GetVaultBuildDate() + if err != nil { + t.Fatalf("failed to set up expectedBuildDate: %v", err) + } + + expectedPluginEnv := &logical.PluginEnvironment{ + VaultVersion: expectedVersionInfo.Version, + VaultVersionPrerelease: expectedVersionInfo.VersionPrerelease, + VaultVersionMetadata: expectedVersionInfo.VersionMetadata, + VaultBuildDate: timestamppb.New(expectedBuildDate), + } + + if !reflect.DeepEqual(pluginEnv, expectedPluginEnv) { + t.Fatalf("got %q, expected %q", pluginEnv, expectedPluginEnv) + } +} + type runes []rune func (r runes) Len() int { return len(r) } diff --git a/vault/testing_util.go b/vault/testing_util.go index 0aff91c60c04..980b214619d4 100644 --- a/vault/testing_util.go +++ b/vault/testing_util.go @@ -1,19 +1,20 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 -//go:build !enterprise - package vault import ( - "crypto/ed25519" - "testing" + "time" + + "github.com/hashicorp/vault/version" ) -func GenerateTestLicenseKeys() (ed25519.PublicKey, ed25519.PrivateKey, error) { return nil, nil, nil } -func testGetLicensingConfig(key ed25519.PublicKey) *LicensingConfig { return &LicensingConfig{} } -func testExtraTestCoreSetup(testing.TB, ed25519.PrivateKey, *TestClusterCore) {} -func testAdjustUnderlyingStorage(tcc *TestClusterCore) { - tcc.UnderlyingStorage = tcc.physical +func init() { + // The BuildDate is set as part of the build process in CI so we need to + // initialize it for testing. By setting it to now minus one year we + // provide some headroom to ensure that test license expiration (for enterprise) + // does not exceed the BuildDate as that is invalid. + if version.BuildDate == "" { + version.BuildDate = time.Now().UTC().AddDate(-1, 0, 0).Format(time.RFC3339) + } } -func testApplyEntBaseConfig(coreConfig, base *CoreConfig) {} diff --git a/vault/testing_util_stubs_oss.go b/vault/testing_util_stubs_oss.go new file mode 100644 index 000000000000..03986cccc3c3 --- /dev/null +++ b/vault/testing_util_stubs_oss.go @@ -0,0 +1,21 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !enterprise + +package vault + +import ( + "crypto/ed25519" + "testing" +) + +//go:generate go run github.com/hashicorp/vault/tools/stubmaker + +func GenerateTestLicenseKeys() (ed25519.PublicKey, ed25519.PrivateKey, error) { return nil, nil, nil } +func testGetLicensingConfig(key ed25519.PublicKey) *LicensingConfig { return &LicensingConfig{} } +func testExtraTestCoreSetup(testing.TB, ed25519.PrivateKey, *TestClusterCore) {} +func testAdjustUnderlyingStorage(tcc *TestClusterCore) { + tcc.UnderlyingStorage = tcc.physical +} +func testApplyEntBaseConfig(coreConfig, base *CoreConfig) {} diff --git a/version/version.go b/version/version.go index eb63e7418021..6cacced9a9c5 100644 --- a/version/version.go +++ b/version/version.go @@ -6,6 +6,7 @@ package version import ( "bytes" "fmt" + "time" ) type VersionInfo struct { @@ -33,6 +34,14 @@ func GetVersion() *VersionInfo { } } +func GetVaultBuildDate() (time.Time, error) { + buildDate, err := time.Parse(time.RFC3339, BuildDate) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse build date based on RFC3339: %w", err) + } + return buildDate, nil +} + func (c *VersionInfo) VersionNumber() string { if Version == "unknown" && VersionPrerelease == "unknown" { return "(version unknown)" From 2b3f5170767332349d708c32fee322b3b833828e Mon Sep 17 00:00:00 2001 From: Angel Garbarino Date: Tue, 10 Dec 2024 16:21:55 -0700 Subject: [PATCH 37/45] Add Azure configuration details view (#29071) * configuration details only changes * azure configuration acceptance test * clean up * change attrs to display attrs and reuse formFields * missed some * clean up * Update ui/app/helpers/mountable-secret-engines.js Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> * remove extra conditional * fix test for oss runs * clean up the logic for checking if the model has been configured * remove formatTtl * fix broken conditional * address pr comments * clean up clean up everybody lets clean up --------- Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> --- ui/app/adapters/azure/config.js | 26 ++++ .../secret-engine/configuration-details.hbs | 67 ++++---- .../secret-engine/configure-aws.hbs | 4 +- ui/app/helpers/mountable-secret-engines.js | 21 ++- ui/app/helpers/supported-secret-backends.js | 1 + ui/app/models/aws/lease-config.js | 2 +- ui/app/models/aws/root-config.js | 2 +- ui/app/models/azure/config.js | 68 +++++++++ ui/app/models/identity/oidc/config.js | 2 +- ui/app/models/ssh/ca-config.js | 5 +- .../secrets/backend/configuration/index.js | 29 +++- .../vault/cluster/secrets/backend/list.js | 8 +- ui/app/serializers/azure/config.js | 23 +++ .../secrets/backend/configuration/index.hbs | 25 +-- .../addon/components/secret-list-header.hbs | 2 +- .../addon/components/secret-list-header.js | 8 + .../backend/aws/aws-configuration-test.js | 12 +- .../backend/azure/azure-configuration-test.js | 143 ++++++++++++++++++ .../secrets/backend/engines-test.js | 43 +++--- .../settings/mount-secret-backend-test.js | 8 +- .../secret-engine/secret-engine-helpers.js | 79 ++++++++++ .../configuration-details-test.js | 7 +- ui/types/vault/models/azure/config.d.ts | 27 ++++ 23 files changed, 518 insertions(+), 94 deletions(-) create mode 100644 ui/app/adapters/azure/config.js create mode 100644 ui/app/models/azure/config.js create mode 100644 ui/app/serializers/azure/config.js create mode 100644 ui/tests/acceptance/secrets/backend/azure/azure-configuration-test.js create mode 100644 ui/types/vault/models/azure/config.d.ts diff --git a/ui/app/adapters/azure/config.js b/ui/app/adapters/azure/config.js new file mode 100644 index 000000000000..26ba67ffae37 --- /dev/null +++ b/ui/app/adapters/azure/config.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import ApplicationAdapter from '../application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; + +export default class AzureConfig extends ApplicationAdapter { + namespace = 'v1'; + + _url(backend) { + return `${this.buildURL()}/${encodePath(backend)}/config`; + } + + queryRecord(store, type, query) { + const { backend } = query; + return this.ajax(this._url(backend), 'GET').then((resp) => { + return { + ...resp, + id: backend, + backend, + }; + }); + } +} diff --git a/ui/app/components/secret-engine/configuration-details.hbs b/ui/app/components/secret-engine/configuration-details.hbs index a65b7cb39b7c..b7c0f703e359 100644 --- a/ui/app/components/secret-engine/configuration-details.hbs +++ b/ui/app/components/secret-engine/configuration-details.hbs @@ -3,47 +3,42 @@ SPDX-License-Identifier: BUSL-1.1 ~}} -{{#if @configModels.length}} - {{#each @configModels as |configModel|}} - {{#each configModel.attrs as |attr|}} - {{! public key while not sensitive when editing/creating, should be hidden by default on viewing }} - {{#if (or attr.options.sensitive (eq attr.name "publicKey"))}} - - {{#if (or attr.options.sensitive (eq attr.name "publicKey"))}} - - {{/if}} - - {{else}} - - {{/if}} - {{/each}} +{{#each @configModels as |configModel|}} + {{#each configModel.displayAttrs as |attr|}} + {{! public key while not sensitive when editing/creating, should be hidden by default on viewing }} + {{#if (or attr.options.sensitive (eq attr.name "publicKey"))}} + + + + {{else}} + + {{/if}} {{/each}} {{else}} {{! Prompt user to configure the secret engine }} - + {{! TODO: short-term conditional to be removed once configuration for azure is merged. }} + {{#unless (eq @typeDisplay "Azure")}} + + {{/unless}} -{{/if}} \ No newline at end of file +{{/each}} \ No newline at end of file diff --git a/ui/app/components/secret-engine/configure-aws.hbs b/ui/app/components/secret-engine/configure-aws.hbs index 46ab7e36c3d9..bc0109f193ef 100644 --- a/ui/app/components/secret-engine/configure-aws.hbs +++ b/ui/app/components/secret-engine/configure-aws.hbs @@ -56,7 +56,7 @@ {{/if}} {{#if (eq this.accessType "wif")}} {{! WIF Fields }} - {{#each @issuerConfig.attrs as |attr|}} + {{#each @issuerConfig.displayAttrs as |attr|}} {{/each}}
- {{#each @leaseConfig.attrs as |attr|}} + {{#each @leaseConfig.displayAttrs as |attr|}} {{/each}}
diff --git a/ui/app/helpers/mountable-secret-engines.js b/ui/app/helpers/mountable-secret-engines.js index e1b65f702a79..f5ac251d2c0f 100644 --- a/ui/app/helpers/mountable-secret-engines.js +++ b/ui/app/helpers/mountable-secret-engines.js @@ -135,23 +135,32 @@ const MOUNTABLE_SECRET_ENGINES = [ ]; // A list of Workload Identity Federation engines. -// Will eventually include Azure and GCP. -export const WIF_ENGINES = ['aws']; +export const WIF_ENGINES = ['aws', 'azure']; export function wifEngines() { return WIF_ENGINES.slice(); } +// The UI only supports configuration views for these secrets engines. The CLI must be used to manage other engine resources (i.e. roles, credentials). +// Will eventually include gcp. +export const CONFIGURATION_ONLY = ['azure']; + +export function configurationOnly() { + return CONFIGURATION_ONLY.slice(); +} + // Secret engines that have their own configuration page and actions // These engines do not exist in their own Ember engine. -export const CONFIGURABLE_SECRET_ENGINES = ['aws', 'ssh']; +export const CONFIGURABLE_SECRET_ENGINES = ['aws', 'azure', 'ssh']; -export function configurableSecretEngines() { +export function mountableEngines() { return MOUNTABLE_SECRET_ENGINES.slice(); } +// secret engines that have not other views than the mount view and mount details view +export const UNSUPPORTED_ENGINES = ['alicloud', 'consul', 'gcp', 'gcpkms', 'nomad', 'rabbitmq', 'totp']; -export function mountableEngines() { - return MOUNTABLE_SECRET_ENGINES.slice(); +export function unsupportedEngines() { + return UNSUPPORTED_ENGINES.slice(); } export function allEngines() { diff --git a/ui/app/helpers/supported-secret-backends.js b/ui/app/helpers/supported-secret-backends.js index 718177276f79..507887eee876 100644 --- a/ui/app/helpers/supported-secret-backends.js +++ b/ui/app/helpers/supported-secret-backends.js @@ -7,6 +7,7 @@ import { helper as buildHelper } from '@ember/component/helper'; const SUPPORTED_SECRET_BACKENDS = [ 'aws', + 'azure', 'cubbyhole', 'database', 'generic', diff --git a/ui/app/models/aws/lease-config.js b/ui/app/models/aws/lease-config.js index 3d7c30cf6ee7..aad4b29f1fba 100644 --- a/ui/app/models/aws/lease-config.js +++ b/ui/app/models/aws/lease-config.js @@ -32,7 +32,7 @@ export default class AwsLeaseConfig extends Model { }) lease; - get attrs() { + get displayAttrs() { const keys = ['lease', 'leaseMax']; return expandAttributeMeta(this, keys); } diff --git a/ui/app/models/aws/root-config.js b/ui/app/models/aws/root-config.js index 0132832246df..882b6c8cb385 100644 --- a/ui/app/models/aws/root-config.js +++ b/ui/app/models/aws/root-config.js @@ -50,7 +50,7 @@ export default class AwsRootConfig extends Model { }) maxRetries; - get attrs() { + get displayAttrs() { const keys = [ 'roleArn', 'identityTokenAudience', diff --git a/ui/app/models/azure/config.js b/ui/app/models/azure/config.js new file mode 100644 index 000000000000..208c2e11c9dc --- /dev/null +++ b/ui/app/models/azure/config.js @@ -0,0 +1,68 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Model, { attr } from '@ember-data/model'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; + +// Note: while the API docs indicate subscriptionId and tenantId are required, the UI does not enforce this because the user may pass these values in as environment variables. +// https://developer.hashicorp.com/vault/api-docs/secret/azure#configure-access +export default class AzureConfig extends Model { + @attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord + @attr('string', { label: 'Subscription ID' }) subscriptionId; + @attr('string', { label: 'Tenant ID' }) tenantId; + @attr('string', { label: 'Client ID' }) clientId; + @attr('string', { sensitive: true }) clientSecret; // obfuscated, never returned by API + @attr('string') environment; + + @attr('string', { + subText: + 'The audience claim value for plugin identity tokens. Must match an allowed audience configured for the target IAM OIDC identity provider.', + }) + identityTokenAudience; + + @attr({ + label: 'Identity token TTL', + helperTextDisabled: + 'The TTL of generated tokens. Defaults to 1 hour, turn on the toggle to specify a different value.', + helperTextEnabled: 'The TTL of generated tokens.', + editType: 'ttl', + }) + identityTokenTtl; + + @attr({ + label: 'Root password TTL', + editType: 'ttl', + helperTextDisabled: + 'Specifies how long the root password is valid for in Azure when rotate-root generates a new client secret. Defaults to 182 days or 6 months, 1 day and 13 hours.', + }) + rootPasswordTtl; + + configurableParams = [ + 'subscriptionId', + 'tenantId', + 'clientId', + 'clientSecret', + 'identityTokenAudience', + 'identityTokenTtl', + 'rootPasswordTtl', + 'environment', + ]; + + // for configuration details view + // do not include clientSecret because it is never returned by the API + get displayAttrs() { + return this.formFields.filter((attr) => attr.name !== 'clientSecret'); + } + + get isConfigured() { + // if every value is falsy, this engine has not been configured yet + return !this.configurableParams.every((param) => !this[param]); + } + + // formFields are iterated through to generate the edit/create view + get formFields() { + return expandAttributeMeta(this, this.configurableParams); + } +} diff --git a/ui/app/models/identity/oidc/config.js b/ui/app/models/identity/oidc/config.js index b08fde96381b..d7925e1b42bf 100644 --- a/ui/app/models/identity/oidc/config.js +++ b/ui/app/models/identity/oidc/config.js @@ -16,7 +16,7 @@ export default class IdentityOidcConfig extends Model { }) issuer; - get attrs() { + get displayAttrs() { const keys = ['issuer']; return expandAttributeMeta(this, keys); } diff --git a/ui/app/models/ssh/ca-config.js b/ui/app/models/ssh/ca-config.js index 382ed730345b..767c543f7f1f 100644 --- a/ui/app/models/ssh/ca-config.js +++ b/ui/app/models/ssh/ca-config.js @@ -42,9 +42,8 @@ export default class SshCaConfig extends Model { generateSigningKey; // do not return private key for configuration.index view - get attrs() { - const keys = ['publicKey', 'generateSigningKey']; - return expandAttributeMeta(this, keys); + get displayAttrs() { + return this.formFields.filter((attr) => attr.name !== 'privateKey'); } // return private key for edit/create view get formFields() { diff --git a/ui/app/routes/vault/cluster/secrets/backend/configuration/index.js b/ui/app/routes/vault/cluster/secrets/backend/configuration/index.js index 15316ae33360..327ffa4329ff 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/configuration/index.js +++ b/ui/app/routes/vault/cluster/secrets/backend/configuration/index.js @@ -68,6 +68,8 @@ export default class SecretsBackendConfigurationRoute extends Route { return this.fetchAwsConfigs(id); case 'ssh': return this.fetchSshCaConfig(id); + case 'azure': + return this.fetchAzureConfig(id); default: return reject({ httpStatus: 404, message: 'not found', path: id }); } @@ -81,8 +83,8 @@ export default class SecretsBackendConfigurationRoute extends Route { const configLease = await this.fetchAwsConfig(id, 'aws/lease-config'); let issuer = null; if (this.version.isEnterprise && configRoot) { - // Issuer is an enterprise only related feature - // Issuer is also a global endpoint that doesn't mean anything in the AWS secret details context if WIF related fields on the rootConfig have not been set. + // issuer is an enterprise only related feature + // issuer is also a global endpoint that doesn't mean anything in the AWS secret details context if WIF related fields on the rootConfig have not been set. const WIF_FIELDS = ['roleArn', 'identityTokenAudience', 'identityTokenTtl']; WIF_FIELDS.some((field) => configRoot[field]) ? (issuer = await this.fetchIssuer()) : null; } @@ -124,6 +126,29 @@ export default class SecretsBackendConfigurationRoute extends Route { } } + async fetchAzureConfig(id) { + try { + const azureModel = await this.store.queryRecord('azure/config', { backend: id }); + let issuer = null; + if (this.version.isEnterprise) { + // Issuer is an enterprise only related feature + // Issuer is also a global endpoint that doesn't mean anything in the Azure secret details context if WIF related fields on the azureConfig have not been set. + const WIF_FIELDS = ['identityTokenAudience', 'identityTokenTtl']; + WIF_FIELDS.some((field) => azureModel[field]) ? (issuer = await this.fetchIssuer()) : null; + } + const configArray = []; + if (azureModel.isConfigured) configArray.push(azureModel); + if (issuer) configArray.push(issuer); + return configArray; + } catch (e) { + if (e.httpStatus === 404) { + // a 404 error is thrown when Azure's config hasn't been set yet. + return; + } + throw e; + } + } + setupController(controller, resolvedModel) { super.setupController(controller, resolvedModel); controller.typeDisplay = allEngines().find( diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js index b3e5cdb09947..70deedf4b926 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/list.js +++ b/ui/app/routes/vault/cluster/secrets/backend/list.js @@ -7,7 +7,7 @@ import { set } from '@ember/object'; import { hash } from 'rsvp'; import Route from '@ember/routing/route'; import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; -import { allEngines, isAddonEngine } from 'vault/helpers/mountable-secret-engines'; +import { allEngines, isAddonEngine, CONFIGURATION_ONLY } from 'vault/helpers/mountable-secret-engines'; import { service } from '@ember/service'; import { normalizePath } from 'vault/utils/path-encoding-helpers'; import { assert } from '@ember/debug'; @@ -84,8 +84,12 @@ export default Route.extend({ const secretEngine = this.store.peekRecord('secret-engine', backend); const type = secretEngine?.engineType; assert('secretEngine.engineType is not defined', !!type); - const engineRoute = allEngines().find((engine) => engine.type === type)?.engineRoute; + // if configuration only, redirect to configuration route + if (CONFIGURATION_ONLY.includes(type)) { + return this.router.transitionTo('vault.cluster.secrets.backend.configuration', backend); + } + const engineRoute = allEngines().find((engine) => engine.type === type)?.engineRoute; if (!type || !SUPPORTED_BACKENDS.includes(type)) { return this.router.transitionTo('vault.cluster.secrets'); } diff --git a/ui/app/serializers/azure/config.js b/ui/app/serializers/azure/config.js new file mode 100644 index 000000000000..7522fed33462 --- /dev/null +++ b/ui/app/serializers/azure/config.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import ApplicationSerializer from '../application'; + +export default class AzureConfigSerializer extends ApplicationSerializer { + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + if (!payload.data) { + return super.normalizeResponse(...arguments); + } + + const normalizedPayload = { + id: payload.id, + backend: payload.backend, + data: { + ...payload.data, + }, + }; + return super.normalizeResponse(store, primaryModelClass, normalizedPayload, id, requestType); + } +} diff --git a/ui/app/templates/vault/cluster/secrets/backend/configuration/index.hbs b/ui/app/templates/vault/cluster/secrets/backend/configuration/index.hbs index abaef090a3e3..dc7b40ed804f 100644 --- a/ui/app/templates/vault/cluster/secrets/backend/configuration/index.hbs +++ b/ui/app/templates/vault/cluster/secrets/backend/configuration/index.hbs @@ -6,17 +6,20 @@ {{#if this.isConfigurable}} - - - - Configure - - - + {{! TODO: short-term conditional to be removed once configuration for azure is merged. }} + {{#unless (eq this.typeDisplay "Azure")}} + + + + Configure + + + + {{/unless}}