diff --git a/api/sudo_paths.go b/api/sudo_paths.go index ae725c4528..f6b89f107f 100644 --- a/api/sudo_paths.go +++ b/api/sudo_paths.go @@ -22,6 +22,7 @@ var sudoPaths = map[string]*regexp.Regexp{ "/sys/audit/{path}": regexp.MustCompile(`^/sys/audit/.+$`), "/sys/auth/{path}": regexp.MustCompile(`^/sys/auth/.+$`), "/sys/auth/{path}/tune": regexp.MustCompile(`^/sys/auth/.+/tune$`), + "/sys/mounts/auth/{path}/tune": regexp.MustCompile(`^/sys/mounts/auth/.+/tune$`), "/sys/config/auditing/request-headers": regexp.MustCompile(`^/sys/config/auditing/request-headers$`), "/sys/config/auditing/request-headers/{header}": regexp.MustCompile(`^/sys/config/auditing/request-headers/.+$`), "/sys/config/cors": regexp.MustCompile(`^/sys/config/cors$`), diff --git a/changelog/_13738.txt b/changelog/_13738.txt new file mode 100644 index 0000000000..e81aeb4a1a --- /dev/null +++ b/changelog/_13738.txt @@ -0,0 +1,3 @@ +```release-note:change +core: Require sudo capability for `sys/mounts/auth//tune`, matching `sys/auth//tune`. +``` diff --git a/sdk/logical/logical.go b/sdk/logical/logical.go index 0d82b8584e..841a518a5b 100644 --- a/sdk/logical/logical.go +++ b/sdk/logical/logical.go @@ -122,7 +122,10 @@ type Factory func(context.Context, *BackendConfig) (Backend, error) // Paths is the structure of special paths that is used for SpecialPaths. type Paths struct { - // Root are the API paths that require a root token to access + // Root are the API paths that require a root token to access. + // These can't be regular expressions; each entry is either an exact match, + // a prefix match (append '*' as a suffix), or a wildcard segment match + // (use '+' in a segment, e.g. 'foo/+/bar'). Root []string // Unauthenticated are the API paths that can be accessed without any auth. diff --git a/vault/external_tests/mountsauthtune/mounts_auth_tune_sudo_test.go b/vault/external_tests/mountsauthtune/mounts_auth_tune_sudo_test.go new file mode 100644 index 0000000000..4a556d6fc1 --- /dev/null +++ b/vault/external_tests/mountsauthtune/mounts_auth_tune_sudo_test.go @@ -0,0 +1,105 @@ +// Copyright IBM Corp. 2016, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package mountsauthtune + +import ( + "fmt" + "testing" + + "github.com/hashicorp/vault/api" + credUserpass "github.com/hashicorp/vault/builtin/credential/userpass" + vaulthttp "github.com/hashicorp/vault/http" + "github.com/hashicorp/vault/sdk/logical" + "github.com/hashicorp/vault/vault" + "github.com/stretchr/testify/require" +) + +// TestMountsAuthTuneRequiresSudo ensures sys/mounts/auth//tune requires +// sudo-equivalent privilege while sys/mounts/auth/ remains readable +// without sudo when ACL allows it. +func TestMountsAuthTuneRequiresSudo(t *testing.T) { + t.Parallel() + + cluster := vault.NewTestCluster(t, &vault.CoreConfig{ + CredentialBackends: map[string]logical.Factory{ + "userpass": credUserpass.Factory, + }, + }, &vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + NumCores: 1, + }) + + client := cluster.Cores[0].Client + rootToken := cluster.RootToken + + require.NoError(t, client.Sys().EnableAuthWithOptions("userpass", &api.EnableAuthOptions{ + Type: "userpass", + })) + + noSudoPolicy := ` +path "sys/mounts/auth/userpass/tune" { + capabilities = ["read", "update"] +} +path "sys/mounts/auth/userpass*" { + capabilities = ["read"] +} +` + require.NoError(t, client.Sys().PutPolicy("mounts-auth-tune-no-sudo", noSudoPolicy)) + + noSudoTokenResp, err := client.Auth().Token().Create(&api.TokenCreateRequest{ + Policies: []string{"mounts-auth-tune-no-sudo"}, + }) + require.NoError(t, err) + require.NotNil(t, noSudoTokenResp) + require.NotNil(t, noSudoTokenResp.Auth) + require.NotEmpty(t, noSudoTokenResp.Auth.ClientToken) + + client.SetToken(noSudoTokenResp.Auth.ClientToken) + + // Non-tune path should remain readable without sudo. + nonTuneResp, err := client.Logical().Read("sys/mounts/auth/userpass") + require.NoError(t, err) + require.NotNil(t, nonTuneResp) + + // Tune endpoints should fail without sudo. + _, err = client.Logical().Write("sys/mounts/auth/userpass/tune", map[string]interface{}{ + "max_lease_ttl": "2h", + }) + require.Error(t, err) + require.Contains(t, err.Error(), logical.ErrPermissionDenied.Error()) + + _, err = client.Logical().Read("sys/mounts/auth/userpass/tune") + require.Error(t, err) + require.Contains(t, err.Error(), logical.ErrPermissionDenied.Error()) + + client.SetToken(rootToken) + + sudoPolicy := ` +path "sys/mounts/auth/userpass/tune" { + capabilities = ["sudo", "read", "update"] +} +` + require.NoError(t, client.Sys().PutPolicy("mounts-auth-tune-with-sudo", sudoPolicy)) + + withSudoTokenResp, err := client.Auth().Token().Create(&api.TokenCreateRequest{ + Policies: []string{"mounts-auth-tune-with-sudo"}, + }) + require.NoError(t, err) + require.NotNil(t, withSudoTokenResp) + require.NotNil(t, withSudoTokenResp.Auth) + require.NotEmpty(t, withSudoTokenResp.Auth.ClientToken) + + client.SetToken(withSudoTokenResp.Auth.ClientToken) + + _, err = client.Logical().Write("sys/mounts/auth/userpass/tune", map[string]interface{}{ + "max_lease_ttl": "3h", + }) + require.NoError(t, err) + + tuneResp, err := client.Logical().Read("sys/mounts/auth/userpass/tune") + require.NoError(t, err) + require.NotNil(t, tuneResp) + require.Contains(t, tuneResp.Data, "max_lease_ttl") + require.Equal(t, "10800", fmt.Sprint(tuneResp.Data["max_lease_ttl"])) +} diff --git a/vault/logical_system.go b/vault/logical_system.go index 006febf4d3..705de4358a 100644 --- a/vault/logical_system.go +++ b/vault/logical_system.go @@ -169,6 +169,7 @@ func NewSystemBackend(core *Core, logger log.Logger, config *logical.BackendConf PathsSpecial: &logical.Paths{ Root: []string{ "auth/*", + "mounts/auth/+/tune", "remount", "audit", "audit/*", diff --git a/vault/logical_system_paths.go b/vault/logical_system_paths.go index 193166c31c..a71f56e997 100644 --- a/vault/logical_system_paths.go +++ b/vault/logical_system_paths.go @@ -4015,7 +4015,7 @@ func (b *SystemBackend) authPaths() []*framework.Path { OperationSuffix: "tuning-information", }, Summary: "Reads the given auth path's configuration.", - Description: "This endpoint requires sudo capability on the final path, but the same functionality can be achieved without sudo via `sys/mounts/auth/[auth-path]/tune`.", + Description: "This endpoint requires sudo capability on the final path. The equivalent endpoint at `sys/mounts/auth/[auth-path]/tune` also requires sudo capability.", Responses: map[int][]framework.Response{ http.StatusOK: {{ Description: "OK", @@ -4030,7 +4030,7 @@ func (b *SystemBackend) authPaths() []*framework.Path { OperationSuffix: "configuration-parameters", }, Summary: "Tune configuration parameters for a given auth path.", - Description: "This endpoint requires sudo capability on the final path, but the same functionality can be achieved without sudo via `sys/mounts/auth/[auth-path]/tune`.", + Description: "This endpoint requires sudo capabilities on the final path. The equivalent endpoint at `sys/mounts/auth/[auth-path]/tune` also requires sudo capabilities.", Responses: map[int][]framework.Response{ http.StatusNoContent: {{ Description: "OK", @@ -4671,7 +4671,7 @@ func (b *SystemBackend) mountsPaths() []*framework.Path { OperationSuffix: "tuning-information", }, Summary: "Reads the given auth path's configuration.", - Description: "This endpoint does NOT require sudo capability. For the sudo-required alternative, use the endpoint at `sys/auth/[auth-path]/tune`.", + Description: "This endpoint requires sudo capability on the final path. The equivalent endpoint at `sys/auth/[auth-path]/tune` also requires sudo capability.", Responses: map[int][]framework.Response{ http.StatusOK: {{ Description: "OK", @@ -4686,7 +4686,7 @@ func (b *SystemBackend) mountsPaths() []*framework.Path { OperationSuffix: "configuration-parameters", }, Summary: "Tune configuration parameters for a given auth path.", - Description: "This endpoint does NOT require sudo capability. The same functionality can be achieved with sudo via the `sys/auth/[auth-path]/tune` endpoint.", + Description: "This endpoint requires sudo capability on the final path. The equivalent endpoint at `sys/auth/[auth-path]/tune` also requires sudo capability.", Responses: map[int][]framework.Response{ http.StatusNoContent: {{ Description: "OK", diff --git a/vault/plugin_reload.go b/vault/plugin_reload.go index 42d6ef6797..c6e7d2cc18 100644 --- a/vault/plugin_reload.go +++ b/vault/plugin_reload.go @@ -270,7 +270,11 @@ func (c *Core) reloadBackendCommon(ctx context.Context, entry *MountEntry, isAut // Set paths as well paths := backend.SpecialPaths() if paths != nil { - re.rootPaths.Store(pathsToRadix(paths.Root)) + rootPathsEntry, err := parseSpecialPaths(paths.Root) + if err != nil { + return err + } + re.rootPaths.Store(rootPathsEntry) loginPathsEntry, err := parseSpecialPaths(paths.Unauthenticated) if err != nil { return err diff --git a/vault/router.go b/vault/router.go index e92810224f..f53b34419c 100644 --- a/vault/router.go +++ b/vault/router.go @@ -208,7 +208,11 @@ func (r *Router) Mount(backend logical.Backend, prefix string, mountEntry *Mount storageView: storageView, } re.tainted.Store(mountEntry.Tainted) - re.rootPaths.Store(pathsToRadix(paths.Root)) + rootPathsEntry, err := parseSpecialPaths(paths.Root) + if err != nil { + return err + } + re.rootPaths.Store(rootPathsEntry) loginPathsEntry, err := parseSpecialPaths(paths.Unauthenticated) if err != nil { return err @@ -886,20 +890,35 @@ func (r *Router) RootPath(ctx context.Context, path string) bool { remain := strings.TrimPrefix(adjustedPath, mount) // Check the rootPaths of this backend - rootPaths := re.rootPaths.Load().(*radix.Tree) - match, raw, ok := rootPaths.LongestPrefix(remain) - if !ok { + rootPaths := re.rootPaths.Load().(*specialPathsEntry) + match, raw, ok := rootPaths.paths.LongestPrefix(remain) + if !ok && len(rootPaths.wildcardPaths) == 0 { return false } - prefixMatch := raw.(bool) - // Handle the prefix match case - if prefixMatch { - return strings.HasPrefix(remain, match) + if ok { + prefixMatch := raw.(bool) + + // Handle the prefix match case + if prefixMatch { + return strings.HasPrefix(remain, match) + } + + // Handle the exact match case + if match == remain { + return true + } } - // Handle the exact match case - return match == remain + // Check root paths containing wildcards + reqPathParts := strings.Split(remain, "/") + for _, w := range rootPaths.wildcardPaths { + if pathMatchesWildcardPath(reqPathParts, w.segments, w.isPrefix) { + return true + } + } + + return false } // LoginPath checks if the given path is used for logins diff --git a/vault/router_test.go b/vault/router_test.go index c845e5c0c1..25e0af008e 100644 --- a/vault/router_test.go +++ b/vault/router_test.go @@ -313,6 +313,7 @@ func TestRouter_RootPath(t *testing.T) { Root: []string{ "root", "policy/*", + "mounts/auth/+/tune", }, } err = r.Mount(n, "prod/aws/", &MountEntry{UUID: meUUID, Accessor: "awsaccessor", NamespaceID: namespace.RootNamespaceID, namespace: namespace.RootNamespace}, view) @@ -332,6 +333,9 @@ func TestRouter_RootPath(t *testing.T) { {"prod/aws/policy", false}, {"prod/aws/policy/", true}, {"prod/aws/policy/ops", true}, + {"prod/aws/mounts/auth/userpass/tune", true}, + {"prod/aws/mounts/auth/userpass", false}, + {"prod/aws/mounts/auth/userpass/roles", false}, } for _, tc := range tcases {