diff --git a/builtin/logical/rabbitmq/path_config_connection.go b/builtin/logical/rabbitmq/path_config_connection.go index 86574dd54cc8..9befe7f0c64e 100644 --- a/builtin/logical/rabbitmq/path_config_connection.go +++ b/builtin/logical/rabbitmq/path_config_connection.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/helper/template" "github.com/hashicorp/vault/sdk/logical" rabbithole "github.com/michaelklishin/rabbit-hole" ) @@ -38,6 +39,10 @@ func pathConfigConnection(b *backend) *framework.Path { Type: framework.TypeString, Description: "Name of the password policy to use to generate passwords for dynamic credentials.", }, + "username_template": { + Type: framework.TypeString, + Description: "Template describing how dynamic usernames are generated.", + }, }, Callbacks: map[logical.Operation]framework.OperationFunc{ @@ -65,6 +70,19 @@ func (b *backend) pathConnectionUpdate(ctx context.Context, req *logical.Request return logical.ErrorResponse("missing password"), nil } + usernameTemplate := data.Get("username_template").(string) + if usernameTemplate != "" { + up, err := template.NewTemplate(template.Template(usernameTemplate)) + if err != nil { + return logical.ErrorResponse("unable to initialize username template: %w", err), nil + } + + _, err = up.Generate(UsernameMetadata{}) + if err != nil { + return logical.ErrorResponse("invalid username template: %w", err), nil + } + } + passwordPolicy := data.Get("password_policy").(string) // Don't check the connection_url if verification is disabled @@ -84,10 +102,11 @@ func (b *backend) pathConnectionUpdate(ctx context.Context, req *logical.Request // Store it config := connectionConfig{ - URI: uri, - Username: username, - Password: password, - PasswordPolicy: passwordPolicy, + URI: uri, + Username: username, + Password: password, + PasswordPolicy: passwordPolicy, + UsernameTemplate: usernameTemplate, } err := writeConfig(ctx, req.Storage, config) if err != nil { @@ -140,6 +159,9 @@ type connectionConfig struct { // PasswordPolicy for generating passwords for dynamic credentials PasswordPolicy string `json:"password_policy"` + + // UsernameTemplate for storing the raw template in Vault's backing data store + UsernameTemplate string `json:"username_template"` } const pathConfigConnectionHelpSyn = ` diff --git a/builtin/logical/rabbitmq/path_config_connection_test.go b/builtin/logical/rabbitmq/path_config_connection_test.go new file mode 100644 index 000000000000..dddee8f0c9b8 --- /dev/null +++ b/builtin/logical/rabbitmq/path_config_connection_test.go @@ -0,0 +1,104 @@ +package rabbitmq + +import ( + "context" + "reflect" + "testing" + + "github.com/hashicorp/vault/sdk/logical" +) + +func TestBackend_ConfigConnection_DefaultUsernameTemplate(t *testing.T) { + var resp *logical.Response + var err error + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + b := Backend() + if err = b.Setup(context.Background(), config); err != nil { + t.Fatal(err) + } + + configData := map[string]interface{}{ + "connection_uri": "uri", + "username": "username", + "password": "password", + "verify_connection": "false", + } + configReq := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "config/connection", + Storage: config.StorageView, + Data: configData, + } + resp, err = b.HandleRequest(context.Background(), configReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr:%s", resp, err) + } + if resp != nil { + t.Fatal("expected a nil response") + } + + actualConfig, err := readConfig(context.Background(), config.StorageView) + if err != nil { + t.Fatalf("unable to read configuration: %v", err) + } + + expectedConfig := connectionConfig{ + URI: "uri", + Username: "username", + Password: "password", + UsernameTemplate: "", + } + + if !reflect.DeepEqual(actualConfig, expectedConfig) { + t.Fatalf("Expected: %#v\nActual: %#v", expectedConfig, actualConfig) + } +} + +func TestBackend_ConfigConnection_CustomUsernameTemplate(t *testing.T) { + var resp *logical.Response + var err error + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + b := Backend() + if err = b.Setup(context.Background(), config); err != nil { + t.Fatal(err) + } + + configData := map[string]interface{}{ + "connection_uri": "uri", + "username": "username", + "password": "password", + "verify_connection": "false", + "username_template": "{{ .DisplayName }}", + } + configReq := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "config/connection", + Storage: config.StorageView, + Data: configData, + } + resp, err = b.HandleRequest(context.Background(), configReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr:%s", resp, err) + } + if resp != nil { + t.Fatal("expected a nil response") + } + + actualConfig, err := readConfig(context.Background(), config.StorageView) + if err != nil { + t.Fatalf("unable to read configuration: %v", err) + } + + expectedConfig := connectionConfig{ + URI: "uri", + Username: "username", + Password: "password", + UsernameTemplate: "{{ .DisplayName }}", + } + + if !reflect.DeepEqual(actualConfig, expectedConfig) { + t.Fatalf("Expected: %#v\nActual: %#v", expectedConfig, actualConfig) + } +} diff --git a/builtin/logical/rabbitmq/path_role_create.go b/builtin/logical/rabbitmq/path_role_create.go index 10f19f081c3b..1ccf0d621e5e 100644 --- a/builtin/logical/rabbitmq/path_role_create.go +++ b/builtin/logical/rabbitmq/path_role_create.go @@ -5,12 +5,16 @@ import ( "fmt" "io/ioutil" - "github.com/hashicorp/go-uuid" "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/helper/template" "github.com/hashicorp/vault/sdk/logical" rabbithole "github.com/michaelklishin/rabbit-hole" ) +const ( + defaultUserNameTemplate = `{{ printf "%s-%s" (.DisplayName) (uuid) }}` +) + func pathCreds(b *backend) *framework.Path { return &framework.Path{ Pattern: "creds/" + framework.GenericNameRegex("name"), @@ -46,17 +50,31 @@ func (b *backend) pathCredsRead(ctx context.Context, req *logical.Request, d *fr return logical.ErrorResponse(fmt.Sprintf("unknown role: %s", name)), nil } - // Ensure username is unique - uuidVal, err := uuid.GenerateUUID() + config, err := readConfig(ctx, req.Storage) if err != nil { - return nil, err + return nil, fmt.Errorf("unable to read configuration: %w", err) } - username := fmt.Sprintf("%s-%s", req.DisplayName, uuidVal) - config, err := readConfig(ctx, req.Storage) + usernameTemplate := config.UsernameTemplate + if usernameTemplate == "" { + usernameTemplate = defaultUserNameTemplate + } + + up, err := template.NewTemplate(template.Template(usernameTemplate)) if err != nil { - return nil, fmt.Errorf("unable to read configuration: %w", err) + return nil, fmt.Errorf("unable to initialize username template: %w", err) + } + + um := UsernameMetadata{ + DisplayName: req.DisplayName, + RoleName: name, + } + + username, err := up.Generate(um) + if err != nil { + return nil, fmt.Errorf("failed to generate username: %w", err) } + fmt.Printf("username: %s\n", username) password, err := b.generatePassword(ctx, config.PasswordPolicy) if err != nil { @@ -189,6 +207,12 @@ func isIn200s(respStatus int) bool { return respStatus >= 200 && respStatus < 300 } +// UsernameMetadata is metadata the database plugin can use to generate a username +type UsernameMetadata struct { + DisplayName string + RoleName string +} + const pathRoleCreateReadHelpSyn = ` Request RabbitMQ credentials for a certain role. ` diff --git a/builtin/logical/rabbitmq/path_role_create_test.go b/builtin/logical/rabbitmq/path_role_create_test.go new file mode 100644 index 000000000000..2c3d5f4b86d6 --- /dev/null +++ b/builtin/logical/rabbitmq/path_role_create_test.go @@ -0,0 +1,161 @@ +package rabbitmq + +import ( + "context" + "testing" + + "github.com/hashicorp/vault/sdk/logical" + "github.com/stretchr/testify/require" +) + +func TestBackend_RoleCreate_DefaultUsernameTemplate(t *testing.T) { + cleanup, connectionURI := prepareRabbitMQTestContainer(t) + defer cleanup() + + var resp *logical.Response + var err error + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + b := Backend() + if err = b.Setup(context.Background(), config); err != nil { + t.Fatal(err) + } + + configData := map[string]interface{}{ + "connection_uri": connectionURI, + "username": "guest", + "password": "guest", + "username_template": "", + } + configReq := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "config/connection", + Storage: config.StorageView, + Data: configData, + } + resp, err = b.HandleRequest(context.Background(), configReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr:%s", resp, err) + } + if resp != nil { + t.Fatal("expected a nil response") + } + + roleData := map[string]interface{}{ + "name": "foo", + "tags": "bar", + } + roleReq := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "roles/foo", + Storage: config.StorageView, + Data: roleData, + } + resp, err = b.HandleRequest(context.Background(), roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr:%s", resp, err) + } + if resp != nil { + t.Fatal("expected a nil response") + } + + credsReq := &logical.Request{ + Operation: logical.ReadOperation, + Path: "creds/foo", + Storage: config.StorageView, + DisplayName: "token", + } + resp, err = b.HandleRequest(context.Background(), credsReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr:%s", resp, err) + } + if resp == nil { + t.Fatal("missing creds response") + } + if resp.Data == nil { + t.Fatalf("missing creds data") + } + + username, exists := resp.Data["username"] + if !exists { + t.Fatalf("missing username in response") + } + + require.Regexp(t, `^token-[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$`, username) +} + +func TestBackend_RoleCreate_CustomUsernameTemplate(t *testing.T) { + cleanup, connectionURI := prepareRabbitMQTestContainer(t) + defer cleanup() + + var resp *logical.Response + var err error + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + b := Backend() + if err = b.Setup(context.Background(), config); err != nil { + t.Fatal(err) + } + + configData := map[string]interface{}{ + "connection_uri": connectionURI, + "username": "guest", + "password": "guest", + "username_template": "foo-{{ .DisplayName }}", + } + configReq := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "config/connection", + Storage: config.StorageView, + Data: configData, + } + resp, err = b.HandleRequest(context.Background(), configReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr:%s", resp, err) + } + if resp != nil { + t.Fatal("expected a nil response") + } + + roleData := map[string]interface{}{ + "name": "foo", + "tags": "bar", + } + roleReq := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "roles/foo", + Storage: config.StorageView, + Data: roleData, + } + resp, err = b.HandleRequest(context.Background(), roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr:%s", resp, err) + } + if resp != nil { + t.Fatal("expected a nil response") + } + + credsReq := &logical.Request{ + Operation: logical.ReadOperation, + Path: "creds/foo", + Storage: config.StorageView, + DisplayName: "token", + } + resp, err = b.HandleRequest(context.Background(), credsReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: resp: %#v\nerr:%s", resp, err) + } + if resp == nil { + t.Fatal("missing creds response") + } + if resp.Data == nil { + t.Fatalf("missing creds data") + } + + username, exists := resp.Data["username"] + if !exists { + t.Fatalf("missing username in response") + } + + require.Regexp(t, `^foo-token$`, username) +} diff --git a/changelog/11899.txt b/changelog/11899.txt new file mode 100644 index 000000000000..9867d9a7231e --- /dev/null +++ b/changelog/11899.txt @@ -0,0 +1,3 @@ +```release-note:feature +secret/rabbitmq: Add ability to customize dynamic usernames +``` diff --git a/website/content/api-docs/secret/rabbitmq.mdx b/website/content/api-docs/secret/rabbitmq.mdx index 3af334a0cda1..e3da050084b5 100644 --- a/website/content/api-docs/secret/rabbitmq.mdx +++ b/website/content/api-docs/secret/rabbitmq.mdx @@ -36,6 +36,9 @@ RabbitMQ. - `password_policy` `(string: "")` - Specifies a [password policy](/docs/concepts/password-policies) to use when creating dynamic credentials. Defaults to generating an alphanumeric password if not set. +- `username_template` `(string)` - [Template](/docs/concepts/username-templating) describing how + dynamic usernames are generated. + ### Sample Payload ```json