Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

database: update plugin to adhere to Database v5 interface #14

Merged
merged 8 commits into from
Oct 12, 2020
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,21 @@ If you believe you have found a security issue in Vault or with this plugin, _pl
contacting HashiCorp at [security@hashicorp.com](mailto:security@hashicorp.com) and contact MongoDB
directly via [security@mongodb.com](mailto:security@mongodb.com) or
[open a ticket](https://jira.mongodb.org/plugins/servlet/samlsso?redirectTo=%2Fbrowse%2FSECURITY) (link is external).

## Acceptance Testing

In order to perform acceptance testing, you need to set the environment
variable `VAULT_ACC=1` as well as provide all the of necessary information to
connect to a MongoDB Atlas Project. All `ATLAS_*` environment variables must be
provided in order for the acceptance tests to run properly. A cluster must be
available during the test. A
[free tier cluster](https://docs.atlas.mongodb.com/tutorial/deploy-free-tier-cluster/)
can be provisioned manually to test.

| Environment variable | Description |
|----------------------|---------------------------------------------------------------|
| ATLAS_PUBLIC_KEY | The Atlas API public key |
| ATLAS_PRIVATE_KEY | The Atlas API private key |
| ATLAS_PROJECT_ID | The desired project ID or group ID |
| ATLAS_CONN_URL | The desired cluster's connection URL within the project |
| ATLAS_ALLOWLIST_IP | The public IP of the machine that the test is being performed |
38 changes: 2 additions & 36 deletions connection_producer.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ package mongodbatlas

import (
"context"
"errors"
"sync"

"github.com/Sectorbob/mlab-ns2/gae/ns/digest"
"github.com/hashicorp/vault/sdk/database/helper/connutil"
"github.com/mitchellh/mapstructure"
"github.com/mongodb/go-client-mongodb-atlas/mongodbatlas"
)

Expand All @@ -23,40 +21,8 @@ type mongoDBAtlasConnectionProducer struct {
sync.Mutex
}

func (c *mongoDBAtlasConnectionProducer) Initialize(ctx context.Context, conf map[string]interface{}, verifyConnection bool) error {
_, err := c.Init(ctx, conf, verifyConnection)
return err
}

// Initialize parses connection configuration.
func (c *mongoDBAtlasConnectionProducer) Init(ctx context.Context, conf map[string]interface{}, verifyConnection bool) (map[string]interface{}, error) {
c.Lock()
defer c.Unlock()

err := mapstructure.WeakDecode(conf, c)
if err != nil {
return nil, err
}

if len(c.PublicKey) == 0 {
return nil, errors.New("public Key is not set")
}

if len(c.PrivateKey) == 0 {
return nil, errors.New("private Key is not set")
}

c.RawConfig = conf

// Set initialized to true at this point since all fields are set,
// and the connection can be established at a later time.
c.Initialized = true

return conf, nil
}

func (c *mongoDBAtlasConnectionProducer) secretValues() map[string]interface{} {
return map[string]interface{}{
func (c *mongoDBAtlasConnectionProducer) secretValues() map[string]string {
return map[string]string{
c.PrivateKey: "[private_key]",
}
}
Expand Down
12 changes: 4 additions & 8 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,10 @@ go 1.12

require (
github.com/Sectorbob/mlab-ns2 v0.0.0-20171030222938-d3aa0c295a8a
github.com/go-test/deep v1.0.2 // indirect
github.com/hashicorp/go-version v1.2.0 // indirect
github.com/hashicorp/vault/api v1.0.5-0.20200215224050-f6547fa8e820
github.com/hashicorp/vault/sdk v0.1.14-0.20200215224050-f6547fa8e820
github.com/hashicorp/vault/api v1.0.5-0.20200519221902-385fac77e20f
github.com/hashicorp/vault/sdk v0.1.14-0.20201001203959-0ff44e5a3c48
github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d // indirect
github.com/mitchellh/mapstructure v1.1.2
github.com/mitchellh/mapstructure v1.3.2
github.com/mongodb/go-client-mongodb-atlas v0.1.2
golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f // indirect
golang.org/x/text v0.3.2 // indirect
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64 // indirect
go.mongodb.org/mongo-driver v1.4.2
)
314 changes: 304 additions & 10 deletions go.sum

Large diffs are not rendered by default.

147 changes: 81 additions & 66 deletions mongodbatlas.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,37 @@ import (
"encoding/json"
"errors"
"fmt"
"time"

"github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/sdk/database/dbplugin"
"github.com/hashicorp/vault/sdk/database/helper/credsutil"
"github.com/hashicorp/vault/sdk/database/helper/dbutil"
"github.com/hashicorp/vault/sdk/database/newdbplugin"
"github.com/mitchellh/mapstructure"
"github.com/mongodb/go-client-mongodb-atlas/mongodbatlas"
)

const mongoDBAtlasTypeName = "mongodbatlas"

// Verify interface is implemented
var _ dbplugin.Database = &MongoDBAtlas{}
var _ newdbplugin.Database = &MongoDBAtlas{}

type MongoDBAtlas struct {
*mongoDBAtlasConnectionProducer
credsutil.CredentialsProducer
}

func New() (interface{}, error) {
db := new()
dbType := dbplugin.NewDatabaseErrorSanitizerMiddleware(db, db.secretValues)
dbType := newdbplugin.NewDatabaseErrorSanitizerMiddleware(db, db.secretValues)
return dbType, nil
}

func new() *MongoDBAtlas {
connProducer := &mongoDBAtlasConnectionProducer{}
connProducer.Type = mongoDBAtlasTypeName

credsProducer := &credsutil.SQLCredentialsProducer{
DisplayNameLen: credsutil.NoneLength,
RoleNameLen: 15,
UsernameLen: 20,
Separator: "-",
connProducer := &mongoDBAtlasConnectionProducer{
Type: mongoDBAtlasTypeName,
}

return &MongoDBAtlas{
mongoDBAtlasConnectionProducer: connProducer,
CredentialsProducer: credsProducer,
}
}

Expand All @@ -54,42 +46,70 @@ func Run(apiTLSConfig *api.TLSConfig) error {
return err
}

dbplugin.Serve(dbType.(dbplugin.Database), api.VaultPluginTLSProvider(apiTLSConfig))
newdbplugin.Serve(dbType.(newdbplugin.Database), api.VaultPluginTLSProvider(apiTLSConfig))

return nil
}

func (m *MongoDBAtlas) CreateUser(ctx context.Context, statements dbplugin.Statements, usernameConfig dbplugin.UsernameConfig, expiration time.Time) (username string, password string, err error) {
// Grab the lock
func (m *MongoDBAtlas) Initialize(ctx context.Context, req newdbplugin.InitializeRequest) (newdbplugin.InitializeResponse, error) {
m.Lock()
defer m.Unlock()

statements = dbutil.StatementCompatibilityHelper(statements)
m.RawConfig = req.Config

if len(statements.Creation) == 0 {
return "", "", dbutil.ErrEmptyCreationStatement
err := mapstructure.WeakDecode(req.Config, m.mongoDBAtlasConnectionProducer)
if err != nil {
return newdbplugin.InitializeResponse{}, err
}

client, err := m.getConnection(ctx)
if err != nil {
return "", "", err
if len(m.PublicKey) == 0 {
return newdbplugin.InitializeResponse{}, errors.New("public Key is not set")
}

if len(m.PrivateKey) == 0 {
return newdbplugin.InitializeResponse{}, errors.New("private Key is not set")
}

// Set initialized to true at this point since all fields are set,
// and the connection can be established at a later time.
m.Initialized = true

resp := newdbplugin.InitializeResponse{
Config: req.Config,
}

return resp, nil
}

func (m *MongoDBAtlas) NewUser(ctx context.Context, req newdbplugin.NewUserRequest) (newdbplugin.NewUserResponse, error) {
// Grab the lock
m.Lock()
defer m.Unlock()

if len(req.Statements.Commands) == 0 {
return newdbplugin.NewUserResponse{}, dbutil.ErrEmptyCreationStatement
}

username, err = m.GenerateUsername(usernameConfig)
client, err := m.getConnection(ctx)
if err != nil {
return "", "", err
return newdbplugin.NewUserResponse{}, err
}

password, err = m.GeneratePassword()
username, err := credsutil.GenerateUsername(
credsutil.DisplayName("", credsutil.NoneLength),
credsutil.RoleName(req.UsernameConfig.RoleName, 15),
credsutil.MaxLength(20),
credsutil.Separator("-"),
)
if err != nil {
return "", "", err
return newdbplugin.NewUserResponse{}, err
}

// Unmarshal statements.CreationStatements into mongodbRoles
// Unmarshal creation statements into mongodb roles
var databaseUser mongoDBAtlasStatement
err = json.Unmarshal([]byte(statements.Creation[0]), &databaseUser)
err = json.Unmarshal([]byte(req.Statements.Commands[0]), &databaseUser)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since any statements beyond the first one are ignored, can you return an error if there are more than one?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that we are currently ignoring the other statements if they are provided (same for the MongoDB implementation/update). Would this be a breaking behavior if this is changed?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically it would since we'd be erroring if they provide more than one command rather than ignoring them, however I think this a bad user experience if we leave it as-is since we claim that we'll do something (additional commands) but don't actually.

Copy link
Member Author

@calvn calvn Oct 9, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Theron pointed me to the elasticsearch bit of code where we do this. I think it's a fair point, though we should probably do the same for the mongodb (non-Atlas) db engine.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the non-Atlas plugin

if err != nil {
return "", "", fmt.Errorf("Error unmarshalling statement %s", err)
return newdbplugin.NewUserResponse{}, fmt.Errorf("error unmarshalling statement %s", err)
}

// Default to "admin" if no db provided
Expand All @@ -98,75 +118,70 @@ func (m *MongoDBAtlas) CreateUser(ctx context.Context, statements dbplugin.State
}

if len(databaseUser.Roles) == 0 {
return "", "", fmt.Errorf("roles array is required in creation statement")
return newdbplugin.NewUserResponse{}, fmt.Errorf("roles array is required in creation statement")
}

databaseUserRequest := &mongodbatlas.DatabaseUser{
Username: username,
Password: password,
Password: req.Password,
DatabaseName: databaseUser.DatabaseName,
Roles: databaseUser.Roles,
}

_, _, err = client.DatabaseUsers.Create(ctx, m.ProjectID, databaseUserRequest)
if err != nil {
return "", "", err
return newdbplugin.NewUserResponse{}, err
}
return username, password, nil
}

// RenewUser is not supported on MongoDB, so this is a no-op.
func (m *MongoDBAtlas) RenewUser(ctx context.Context, statements dbplugin.Statements, username string, expiration time.Time) error {
// NOOP
return nil
}

// RevokeUser drops the specified user from the authentication database. If none is provided
// in the revocation statement, the default "admin" authentication database will be assumed.
func (m *MongoDBAtlas) RevokeUser(ctx context.Context, statements dbplugin.Statements, username string) error {
m.Lock()
defer m.Unlock()
resp := newdbplugin.NewUserResponse{
Username: username,
}

statements = dbutil.StatementCompatibilityHelper(statements)
return resp, nil
}

client, err := m.getConnection(ctx)
if err != nil {
return err
func (m *MongoDBAtlas) UpdateUser(ctx context.Context, req newdbplugin.UpdateUserRequest) (newdbplugin.UpdateUserResponse, error) {
if req.Password != nil {
err := m.changePassword(ctx, req.Username, req.Password.NewPassword)
return newdbplugin.UpdateUserResponse{}, err
}

_, err = client.DatabaseUsers.Delete(ctx, m.ProjectID, username)
return err
// This also results in an no-op if the expiration is updated due to renewal.
return newdbplugin.UpdateUserResponse{}, nil
}

// SetCredentials uses provided information to set/create a user in the
// database. Unlike CreateUser, this method requires a username be provided and
// uses the name given, instead of generating a name. This is used for creating
// and setting the password of static accounts, as well as rolling back
// passwords in the database in the event an updated database fails to save in
// Vault's storage.
func (m *MongoDBAtlas) SetCredentials(ctx context.Context, statements dbplugin.Statements, staticUser dbplugin.StaticUserConfig) (username, password string, err error) {
// Grab the lock
func (m *MongoDBAtlas) changePassword(ctx context.Context, username, password string) error {
m.Lock()
defer m.Unlock()

client, err := m.getConnection(ctx)
if err != nil {
return "", "", err
return err
}

username = staticUser.Username
password = staticUser.Password

databaseUserRequest := &mongodbatlas.DatabaseUser{
Password: password,
}

_, _, err = client.DatabaseUsers.Update(context.Background(), m.ProjectID, username, databaseUserRequest)
if err != nil {
return "", "", err
return err
}

return nil
calvn marked this conversation as resolved.
Show resolved Hide resolved
}

func (m *MongoDBAtlas) DeleteUser(ctx context.Context, req newdbplugin.DeleteUserRequest) (newdbplugin.DeleteUserResponse, error) {
m.Lock()
defer m.Unlock()

client, err := m.getConnection(ctx)
if err != nil {
return newdbplugin.DeleteUserResponse{}, err
}

return username, password, nil
_, err = client.DatabaseUsers.Delete(ctx, m.ProjectID, req.Username)
return newdbplugin.DeleteUserResponse{}, err
}

func (m *MongoDBAtlas) getConnection(ctx context.Context) (*mongodbatlas.Client, error) {
Expand Down