diff --git a/CHANGELOG.md b/CHANGELOG.md
index 758f0cbfed..5a0f25e407 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -78,6 +78,7 @@ IMPROVEMENTS:
backend [GH-1404]
* credential/ldap: If `groupdn` is not configured, skip searching LDAP and
only return policies for local groups, plus a warning [GH-1283]
+ * credential/ldap: `vault list` support for users and groups [GH-1270]
* credential/userpass: Add list support for users [GH-911]
* credential/userpass: Remove user configuration paths from requiring sudo, in
favor of normal ACL mechanisms [GH-1312]
@@ -114,6 +115,8 @@ BUG FIXES:
* credential/various: Fix renewal conditions when `default` policy is not
contained in the backend config [GH-1256]
* physical/s3: Don't panic in certain error cases from bad S3 responses [GH-1353]
+ * secret/consul: Use non-pooled Consul API client to avoid leaving files open
+ [GH-1428]
* secret/pki: Don't check whether a certificate is destined to be a CA
certificate if sign-verbatim endpoint is used [GH-1250]
diff --git a/builtin/credential/aws/backend.go b/builtin/credential/aws/backend.go
index e0f56d3e8d..2a11f1bf88 100644
--- a/builtin/credential/aws/backend.go
+++ b/builtin/credential/aws/backend.go
@@ -114,7 +114,7 @@ func (b *backend) periodicFunc(req *logical.Request) error {
if b.nextTidyTime.IsZero() || !time.Now().UTC().Before(b.nextTidyTime) {
// safety_buffer defaults to 180 days for roletag blacklist
safety_buffer := 15552000
- tidyBlacklistConfigEntry, err := b.configTidyRoleTags(req.Storage)
+ tidyBlacklistConfigEntry, err := b.lockedConfigTidyRoleTags(req.Storage)
if err != nil {
return err
}
@@ -135,7 +135,7 @@ func (b *backend) periodicFunc(req *logical.Request) error {
// reset the safety_buffer to 72h
safety_buffer = 259200
- tidyWhitelistConfigEntry, err := b.configTidyIdentities(req.Storage)
+ tidyWhitelistConfigEntry, err := b.lockedConfigTidyIdentities(req.Storage)
if err != nil {
return err
}
diff --git a/builtin/credential/aws/backend_test.go b/builtin/credential/aws/backend_test.go
index 06a27a3084..4a69fa700d 100644
--- a/builtin/credential/aws/backend_test.go
+++ b/builtin/credential/aws/backend_test.go
@@ -98,7 +98,7 @@ func TestBackend_CreateParseVerifyRoleTag(t *testing.T) {
}
// read the created role entry
- roleEntry, err := b.awsRole(storage, "abcd-123")
+ roleEntry, err := b.lockedAWSRole(storage, "abcd-123")
if err != nil {
t.Fatal(err)
}
@@ -165,7 +165,7 @@ func TestBackend_CreateParseVerifyRoleTag(t *testing.T) {
}
// get the entry of the newly created role entry
- roleEntry2, err := b.awsRole(storage, "ami-6789")
+ roleEntry2, err := b.lockedAWSRole(storage, "ami-6789")
if err != nil {
t.Fatal(err)
}
@@ -1098,7 +1098,7 @@ func TestBackend_PathBlacklistRoleTag(t *testing.T) {
}
// try to read the deleted entry
- tagEntry, err := b.blacklistRoleTagEntry(storage, tag)
+ tagEntry, err := b.lockedBlacklistRoleTagEntry(storage, tag)
if err != nil {
t.Fatal(err)
}
diff --git a/builtin/credential/aws/client.go b/builtin/credential/aws/client.go
index f08b7c04ae..01259be690 100644
--- a/builtin/credential/aws/client.go
+++ b/builtin/credential/aws/client.go
@@ -24,7 +24,7 @@ func (b *backend) getClientConfig(s logical.Storage, region string) (*aws.Config
}
// Read the configured secret key and access key
- config, err := b.clientConfigEntryInternal(s)
+ config, err := b.nonLockedClientConfigEntry(s)
if err != nil {
return nil, err
}
diff --git a/builtin/credential/aws/path_config_certificate.go b/builtin/credential/aws/path_config_certificate.go
index 86c91ce9c5..7d1efe8571 100644
--- a/builtin/credential/aws/path_config_certificate.go
+++ b/builtin/credential/aws/path_config_certificate.go
@@ -96,7 +96,7 @@ func (b *backend) pathConfigCertificateExistenceCheck(req *logical.Request, data
return false, fmt.Errorf("missing cert_name")
}
- entry, err := b.awsPublicCertificateEntry(req.Storage, certName)
+ entry, err := b.lockedAWSPublicCertificateEntry(req.Storage, certName)
if err != nil {
return false, err
}
@@ -161,7 +161,7 @@ func (b *backend) awsPublicCertificates(s logical.Storage) ([]*x509.Certificate,
// Iterate through each certificate, parse and append it to a slice.
for _, cert := range registeredCerts {
- certEntry, err := b.awsPublicCertificateEntryInternal(s, cert)
+ certEntry, err := b.nonLockedAWSPublicCertificateEntry(s, cert)
if err != nil {
return nil, err
}
@@ -180,15 +180,15 @@ func (b *backend) awsPublicCertificates(s logical.Storage) ([]*x509.Certificate,
// awsPublicCertificate is used to get the configured AWS Public Key that is used
// to verify the PKCS#7 signature of the instance identity document.
-func (b *backend) awsPublicCertificateEntry(s logical.Storage, certName string) (*awsPublicCert, error) {
+func (b *backend) lockedAWSPublicCertificateEntry(s logical.Storage, certName string) (*awsPublicCert, error) {
b.configMutex.RLock()
defer b.configMutex.RUnlock()
- return b.awsPublicCertificateEntryInternal(s, certName)
+ return b.nonLockedAWSPublicCertificateEntry(s, certName)
}
// Internal version of the above that does no locking
-func (b *backend) awsPublicCertificateEntryInternal(s logical.Storage, certName string) (*awsPublicCert, error) {
+func (b *backend) nonLockedAWSPublicCertificateEntry(s logical.Storage, certName string) (*awsPublicCert, error) {
entry, err := s.Get("config/certificate/" + certName)
if err != nil {
return nil, err
@@ -227,7 +227,7 @@ func (b *backend) pathConfigCertificateRead(
return logical.ErrorResponse("missing cert_name"), nil
}
- certificateEntry, err := b.awsPublicCertificateEntry(req.Storage, certName)
+ certificateEntry, err := b.lockedAWSPublicCertificateEntry(req.Storage, certName)
if err != nil {
return nil, err
}
@@ -253,7 +253,7 @@ func (b *backend) pathConfigCertificateCreateUpdate(
defer b.configMutex.Unlock()
// Check if there is already a certificate entry registered.
- certEntry, err := b.awsPublicCertificateEntryInternal(req.Storage, certName)
+ certEntry, err := b.nonLockedAWSPublicCertificateEntry(req.Storage, certName)
if err != nil {
return nil, err
}
diff --git a/builtin/credential/aws/path_config_client.go b/builtin/credential/aws/path_config_client.go
index 38e2ad7ffa..eea42546c7 100644
--- a/builtin/credential/aws/path_config_client.go
+++ b/builtin/credential/aws/path_config_client.go
@@ -48,23 +48,23 @@ func pathConfigClient(b *backend) *framework.Path {
func (b *backend) pathConfigClientExistenceCheck(
req *logical.Request, data *framework.FieldData) (bool, error) {
- entry, err := b.clientConfigEntry(req.Storage)
+ entry, err := b.lockedClientConfigEntry(req.Storage)
if err != nil {
return false, err
}
return entry != nil, nil
}
-// Fetch the client configuration required to access the AWS API.
-func (b *backend) clientConfigEntry(s logical.Storage) (*clientConfig, error) {
+// Fetch the client configuration required to access the AWS API, after acquiring an exclusive lock.
+func (b *backend) lockedClientConfigEntry(s logical.Storage) (*clientConfig, error) {
b.configMutex.RLock()
defer b.configMutex.RUnlock()
- return b.clientConfigEntryInternal(s)
+ return b.nonLockedClientConfigEntry(s)
}
-// Internal version that does no locking
-func (b *backend) clientConfigEntryInternal(s logical.Storage) (*clientConfig, error) {
+// Fetch the client configuration required to access the AWS API.
+func (b *backend) nonLockedClientConfigEntry(s logical.Storage) (*clientConfig, error) {
entry, err := s.Get("config/client")
if err != nil {
return nil, err
@@ -82,7 +82,7 @@ func (b *backend) clientConfigEntryInternal(s logical.Storage) (*clientConfig, e
func (b *backend) pathConfigClientRead(
req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
- clientConfig, err := b.clientConfigEntry(req.Storage)
+ clientConfig, err := b.lockedClientConfigEntry(req.Storage)
if err != nil {
return nil, err
}
@@ -118,7 +118,7 @@ func (b *backend) pathConfigClientCreateUpdate(
b.configMutex.Lock()
defer b.configMutex.Unlock()
- configEntry, err := b.clientConfigEntryInternal(req.Storage)
+ configEntry, err := b.nonLockedClientConfigEntry(req.Storage)
if err != nil {
return nil, err
}
diff --git a/builtin/credential/aws/path_config_tidy_identity_whitelist.go b/builtin/credential/aws/path_config_tidy_identity_whitelist.go
index 700e5fa21a..1ee5ed7f08 100644
--- a/builtin/credential/aws/path_config_tidy_identity_whitelist.go
+++ b/builtin/credential/aws/path_config_tidy_identity_whitelist.go
@@ -44,21 +44,21 @@ expiration, before it is removed from the backend storage.`,
}
func (b *backend) pathConfigTidyIdentityWhitelistExistenceCheck(req *logical.Request, data *framework.FieldData) (bool, error) {
- entry, err := b.configTidyIdentities(req.Storage)
+ entry, err := b.lockedConfigTidyIdentities(req.Storage)
if err != nil {
return false, err
}
return entry != nil, nil
}
-func (b *backend) configTidyIdentities(s logical.Storage) (*tidyWhitelistIdentityConfig, error) {
+func (b *backend) lockedConfigTidyIdentities(s logical.Storage) (*tidyWhitelistIdentityConfig, error) {
b.configMutex.RLock()
defer b.configMutex.RUnlock()
- return b.configTidyIdentitiesInternal(s)
+ return b.nonLockedConfigTidyIdentities(s)
}
-func (b *backend) configTidyIdentitiesInternal(s logical.Storage) (*tidyWhitelistIdentityConfig, error) {
+func (b *backend) nonLockedConfigTidyIdentities(s logical.Storage) (*tidyWhitelistIdentityConfig, error) {
entry, err := s.Get(identityWhitelistConfigPath)
if err != nil {
return nil, err
@@ -78,7 +78,7 @@ func (b *backend) pathConfigTidyIdentityWhitelistCreateUpdate(req *logical.Reque
b.configMutex.Lock()
defer b.configMutex.Unlock()
- configEntry, err := b.configTidyIdentitiesInternal(req.Storage)
+ configEntry, err := b.nonLockedConfigTidyIdentities(req.Storage)
if err != nil {
return nil, err
}
@@ -113,7 +113,7 @@ func (b *backend) pathConfigTidyIdentityWhitelistCreateUpdate(req *logical.Reque
}
func (b *backend) pathConfigTidyIdentityWhitelistRead(req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
- clientConfig, err := b.configTidyIdentities(req.Storage)
+ clientConfig, err := b.lockedConfigTidyIdentities(req.Storage)
if err != nil {
return nil, err
}
diff --git a/builtin/credential/aws/path_config_tidy_roletag_blacklist.go b/builtin/credential/aws/path_config_tidy_roletag_blacklist.go
index 6932f5ec96..1d834030b0 100644
--- a/builtin/credential/aws/path_config_tidy_roletag_blacklist.go
+++ b/builtin/credential/aws/path_config_tidy_roletag_blacklist.go
@@ -46,21 +46,21 @@ Defaults to 4320h (180 days).`,
}
func (b *backend) pathConfigTidyRoletagBlacklistExistenceCheck(req *logical.Request, data *framework.FieldData) (bool, error) {
- entry, err := b.configTidyRoleTags(req.Storage)
+ entry, err := b.lockedConfigTidyRoleTags(req.Storage)
if err != nil {
return false, err
}
return entry != nil, nil
}
-func (b *backend) configTidyRoleTags(s logical.Storage) (*tidyBlacklistRoleTagConfig, error) {
+func (b *backend) lockedConfigTidyRoleTags(s logical.Storage) (*tidyBlacklistRoleTagConfig, error) {
b.configMutex.RLock()
defer b.configMutex.RUnlock()
- return b.configTidyRoleTagsInternal(s)
+ return b.nonLockedConfigTidyRoleTags(s)
}
-func (b *backend) configTidyRoleTagsInternal(s logical.Storage) (*tidyBlacklistRoleTagConfig, error) {
+func (b *backend) nonLockedConfigTidyRoleTags(s logical.Storage) (*tidyBlacklistRoleTagConfig, error) {
entry, err := s.Get(roletagBlacklistConfigPath)
if err != nil {
return nil, err
@@ -81,7 +81,7 @@ func (b *backend) pathConfigTidyRoletagBlacklistCreateUpdate(req *logical.Reques
b.configMutex.Lock()
defer b.configMutex.Unlock()
- configEntry, err := b.configTidyRoleTagsInternal(req.Storage)
+ configEntry, err := b.nonLockedConfigTidyRoleTags(req.Storage)
if err != nil {
return nil, err
}
@@ -114,7 +114,7 @@ func (b *backend) pathConfigTidyRoletagBlacklistCreateUpdate(req *logical.Reques
}
func (b *backend) pathConfigTidyRoletagBlacklistRead(req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
- clientConfig, err := b.configTidyRoleTags(req.Storage)
+ clientConfig, err := b.lockedConfigTidyRoleTags(req.Storage)
if err != nil {
return nil, err
}
diff --git a/builtin/credential/aws/path_login.go b/builtin/credential/aws/path_login.go
index 7aecff4461..f2c65b13b4 100644
--- a/builtin/credential/aws/path_login.go
+++ b/builtin/credential/aws/path_login.go
@@ -236,7 +236,7 @@ func (b *backend) pathLoginUpdate(
}
// Get the entry for the role used by the instance.
- roleEntry, err := b.awsRole(req.Storage, roleName)
+ roleEntry, err := b.lockedAWSRole(req.Storage, roleName)
if err != nil {
return nil, err
}
@@ -442,7 +442,7 @@ func (b *backend) handleRoleTagLogin(s logical.Storage, identityDoc *identityDoc
}
// Check if the role tag is blacklisted.
- blacklistEntry, err := b.blacklistRoleTagEntry(s, rTagValue)
+ blacklistEntry, err := b.lockedBlacklistRoleTagEntry(s, rTagValue)
if err != nil {
return nil, err
}
@@ -487,7 +487,7 @@ func (b *backend) pathLoginRenew(
}
// Ensure that role entry is not deleted.
- roleEntry, err := b.awsRole(req.Storage, storedIdentity.Role)
+ roleEntry, err := b.lockedAWSRole(req.Storage, storedIdentity.Role)
if err != nil {
return nil, err
}
diff --git a/builtin/credential/aws/path_role.go b/builtin/credential/aws/path_role.go
index adb8698763..e65bbfa252 100644
--- a/builtin/credential/aws/path_role.go
+++ b/builtin/credential/aws/path_role.go
@@ -101,7 +101,7 @@ func pathListRoles(b *backend) *framework.Path {
// Establishes dichotomy of request operation between CreateOperation and UpdateOperation.
// Returning 'true' forces an UpdateOperation, CreateOperation otherwise.
func (b *backend) pathRoleExistenceCheck(req *logical.Request, data *framework.FieldData) (bool, error) {
- entry, err := b.awsRole(req.Storage, strings.ToLower(data.Get("role").(string)))
+ entry, err := b.lockedAWSRole(req.Storage, strings.ToLower(data.Get("role").(string)))
if err != nil {
return false, err
}
@@ -109,14 +109,14 @@ func (b *backend) pathRoleExistenceCheck(req *logical.Request, data *framework.F
}
// awsRole is used to get the information registered for the given AMI ID.
-func (b *backend) awsRole(s logical.Storage, role string) (*awsRoleEntry, error) {
+func (b *backend) lockedAWSRole(s logical.Storage, role string) (*awsRoleEntry, error) {
b.roleMutex.RLock()
defer b.roleMutex.RUnlock()
- return b.awsRoleInternal(s, role)
+ return b.nonLockedAWSRole(s, role)
}
-func (b *backend) awsRoleInternal(s logical.Storage, role string) (*awsRoleEntry, error) {
+func (b *backend) nonLockedAWSRole(s logical.Storage, role string) (*awsRoleEntry, error) {
entry, err := s.Get("role/" + strings.ToLower(role))
if err != nil {
return nil, err
@@ -162,7 +162,7 @@ func (b *backend) pathRoleList(
// pathRoleRead is used to view the information registered for a given AMI ID.
func (b *backend) pathRoleRead(
req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
- roleEntry, err := b.awsRole(req.Storage, strings.ToLower(data.Get("role").(string)))
+ roleEntry, err := b.lockedAWSRole(req.Storage, strings.ToLower(data.Get("role").(string)))
if err != nil {
return nil, err
}
@@ -196,7 +196,7 @@ func (b *backend) pathRoleCreateUpdate(
b.roleMutex.Lock()
defer b.roleMutex.Unlock()
- roleEntry, err := b.awsRoleInternal(req.Storage, roleName)
+ roleEntry, err := b.nonLockedAWSRole(req.Storage, roleName)
if err != nil {
return nil, err
}
diff --git a/builtin/credential/aws/path_role_tag.go b/builtin/credential/aws/path_role_tag.go
index 4929bc07b8..bf48a14b82 100644
--- a/builtin/credential/aws/path_role_tag.go
+++ b/builtin/credential/aws/path_role_tag.go
@@ -78,7 +78,7 @@ func (b *backend) pathRoleTagUpdate(
}
// Fetch the role entry
- roleEntry, err := b.awsRole(req.Storage, roleName)
+ roleEntry, err := b.lockedAWSRole(req.Storage, roleName)
if err != nil {
return nil, err
}
@@ -346,7 +346,7 @@ func (b *backend) parseAndVerifyRoleTagValue(s logical.Storage, tag string) (*ro
return nil, fmt.Errorf("missing role name")
}
- roleEntry, err := b.awsRole(s, rTag.Role)
+ roleEntry, err := b.lockedAWSRole(s, rTag.Role)
if err != nil {
return nil, err
}
diff --git a/builtin/credential/aws/path_roletag_blacklist.go b/builtin/credential/aws/path_roletag_blacklist.go
index ff7362449d..0bbe33e5c2 100644
--- a/builtin/credential/aws/path_roletag_blacklist.go
+++ b/builtin/credential/aws/path_roletag_blacklist.go
@@ -72,14 +72,14 @@ func (b *backend) pathRoletagBlacklistsList(
// Fetch an entry from the role tag blacklist for a given tag.
// This method takes a role tag in its original form and not a base64 encoded form.
-func (b *backend) blacklistRoleTagEntry(s logical.Storage, tag string) (*roleTagBlacklistEntry, error) {
+func (b *backend) lockedBlacklistRoleTagEntry(s logical.Storage, tag string) (*roleTagBlacklistEntry, error) {
b.blacklistMutex.RLock()
defer b.blacklistMutex.RUnlock()
- return b.blacklistRoleTagEntryInternal(s, tag)
+ return b.nonLockedBlacklistRoleTagEntry(s, tag)
}
-func (b *backend) blacklistRoleTagEntryInternal(s logical.Storage, tag string) (*roleTagBlacklistEntry, error) {
+func (b *backend) nonLockedBlacklistRoleTagEntry(s logical.Storage, tag string) (*roleTagBlacklistEntry, error) {
entry, err := s.Get("blacklist/roletag/" + base64.StdEncoding.EncodeToString([]byte(tag)))
if err != nil {
return nil, err
@@ -119,7 +119,7 @@ func (b *backend) pathRoletagBlacklistRead(
return logical.ErrorResponse("missing role_tag"), nil
}
- entry, err := b.blacklistRoleTagEntry(req.Storage, tag)
+ entry, err := b.lockedBlacklistRoleTagEntry(req.Storage, tag)
if err != nil {
return nil, err
}
@@ -166,7 +166,7 @@ func (b *backend) pathRoletagBlacklistUpdate(
}
// Get the entry for the role mentioned in the role tag.
- roleEntry, err := b.awsRole(req.Storage, rTag.Role)
+ roleEntry, err := b.lockedAWSRole(req.Storage, rTag.Role)
if err != nil {
return nil, err
}
@@ -178,7 +178,7 @@ func (b *backend) pathRoletagBlacklistUpdate(
defer b.blacklistMutex.Unlock()
// Check if the role tag is already blacklisted. If yes, update it.
- blEntry, err := b.blacklistRoleTagEntryInternal(req.Storage, tag)
+ blEntry, err := b.nonLockedBlacklistRoleTagEntry(req.Storage, tag)
if err != nil {
return nil, err
}
diff --git a/builtin/credential/ldap/backend.go b/builtin/credential/ldap/backend.go
index ad29bd5a5b..70e1d62da0 100644
--- a/builtin/credential/ldap/backend.go
+++ b/builtin/credential/ldap/backend.go
@@ -35,7 +35,9 @@ func Backend() *framework.Backend {
Paths: append([]*framework.Path{
pathConfig(&b),
pathGroups(&b),
+ pathGroupsList(&b),
pathUsers(&b),
+ pathUsersList(&b),
},
mfa.MFAPaths(b.Backend, pathLogin(&b))...,
),
diff --git a/builtin/credential/ldap/backend_test.go b/builtin/credential/ldap/backend_test.go
index c8e21be70e..5323c8eea8 100644
--- a/builtin/credential/ldap/backend_test.go
+++ b/builtin/credential/ldap/backend_test.go
@@ -2,6 +2,7 @@ package ldap
import (
"fmt"
+ "reflect"
"testing"
"time"
@@ -38,6 +39,8 @@ func TestBackend_basic(t *testing.T) {
testAccStepGroup(t, "engineers", "bar"),
testAccStepUser(t, "tesla", "engineers"),
testAccStepLogin(t, "tesla", "password"),
+ testAccStepGroupList(t, []string{"engineers", "scientists"}),
+ testAccStepUserList(t, []string{"tesla"}),
},
})
}
@@ -321,3 +324,39 @@ func TestLDAPEscape(t *testing.T) {
}
}
}
+
+func testAccStepGroupList(t *testing.T, groups []string) logicaltest.TestStep {
+ return logicaltest.TestStep{
+ Operation: logical.ListOperation,
+ Path: "groups",
+ Check: func(resp *logical.Response) error {
+ if resp.IsError() {
+ return fmt.Errorf("Got error response: %#v", *resp)
+ }
+
+ exp := groups
+ if !reflect.DeepEqual(exp, resp.Data["keys"].([]string)) {
+ return fmt.Errorf("expected:\n%#v\ngot:\n%#v\n", exp, resp.Data["keys"])
+ }
+ return nil
+ },
+ }
+}
+
+func testAccStepUserList(t *testing.T, users []string) logicaltest.TestStep {
+ return logicaltest.TestStep{
+ Operation: logical.ListOperation,
+ Path: "users",
+ Check: func(resp *logical.Response) error {
+ if resp.IsError() {
+ return fmt.Errorf("Got error response: %#v", *resp)
+ }
+
+ exp := users
+ if !reflect.DeepEqual(exp, resp.Data["keys"].([]string)) {
+ return fmt.Errorf("expected:\n%#v\ngot:\n%#v\n", exp, resp.Data["keys"])
+ }
+ return nil
+ },
+ }
+}
diff --git a/builtin/credential/ldap/path_groups.go b/builtin/credential/ldap/path_groups.go
index c13ead699b..998fdc41b6 100644
--- a/builtin/credential/ldap/path_groups.go
+++ b/builtin/credential/ldap/path_groups.go
@@ -8,6 +8,19 @@ import (
"github.com/hashicorp/vault/logical/framework"
)
+func pathGroupsList(b *backend) *framework.Path {
+ return &framework.Path{
+ Pattern: "groups/?$",
+
+ Callbacks: map[logical.Operation]framework.OperationFunc{
+ logical.ListOperation: b.pathGroupList,
+ },
+
+ HelpSynopsis: pathGroupHelpSyn,
+ HelpDescription: pathGroupHelpDesc,
+ }
+}
+
func pathGroups(b *backend) *framework.Path {
return &framework.Path{
Pattern: `groups/(?P
+Training: +Paid HashiCorp training courses +are also available in a city near you. Private training courses are also available. +