diff --git a/builtin/logical/mssql/backend.go b/builtin/logical/mssql/backend.go new file mode 100644 index 0000000000..1b6b88991c --- /dev/null +++ b/builtin/logical/mssql/backend.go @@ -0,0 +1,138 @@ +package mssql + +import ( + "database/sql" + "fmt" + "strings" + "sync" + + _ "github.com/denisenkom/go-mssqldb" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func Factory(conf *logical.BackendConfig) (logical.Backend, error) { + return Backend().Setup(conf) +} + +func Backend() *framework.Backend { + var b backend + b.Backend = &framework.Backend{ + Help: strings.TrimSpace(backendHelp), + + PathsSpecial: &logical.Paths{ + Root: []string{ + "config/*", + }, + }, + + Paths: []*framework.Path{ + pathConfigConnection(&b), + pathConfigLease(&b), + pathListRoles(&b), + pathRoles(&b), + pathCredsCreate(&b), + }, + + Secrets: []*framework.Secret{ + secretCreds(&b), + }, + } + + return b.Backend +} + +type backend struct { + *framework.Backend + + db *sql.DB + defaultDb string + lock sync.Mutex +} + +// DB returns the default database connection. +func (b *backend) DB(s logical.Storage) (*sql.DB, error) { + b.lock.Lock() + defer b.lock.Unlock() + + // If we already have a DB, we got it! + if b.db != nil { + return b.db, nil + } + + // Otherwise, attempt to make connection + entry, err := s.Get("config/connection") + if err != nil { + return nil, err + } + if entry == nil { + return nil, fmt.Errorf("configure the DB connection with config/connection first") + } + + var connConfig connectionConfig + if err := entry.DecodeJSON(&connConfig); err != nil { + return nil, err + } + conn := connConfig.ConnectionParams + + b.db, err = sql.Open("mssql", BuildDsn(conn)) + if err != nil { + return nil, err + } + + // Set some connection pool settings. We don't need much of this, + // since the request rate shouldn't be high. + b.db.SetMaxOpenConns(connConfig.MaxOpenConnections) + + stmt, err := b.db.Prepare("SELECT db_name();") + if err != nil { + return nil, err + } + defer stmt.Close() + + err = stmt.QueryRow().Scan(&b.defaultDb) + if err != nil { + return nil, err + } + + return b.db, nil +} + +// ResetDB forces a connection next time DB() is called. +func (b *backend) ResetDB() { + b.lock.Lock() + defer b.lock.Unlock() + + if b.db != nil { + b.db.Close() + } + + b.db = nil +} + +// Lease returns the lease information +func (b *backend) Lease(s logical.Storage) (*configLease, error) { + entry, err := s.Get("config/lease") + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + + var result configLease + if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + + return &result, nil +} + +const backendHelp = ` +The MSSQL backend dynamically generates database users. + +After mounting this backend, configure it using the endpoints within +the "config/" path. + +This backend does not support Azure SQL Databases +` diff --git a/builtin/logical/mssql/backend_test.go b/builtin/logical/mssql/backend_test.go new file mode 100644 index 0000000000..48382f381b --- /dev/null +++ b/builtin/logical/mssql/backend_test.go @@ -0,0 +1,169 @@ +package mssql + +import ( + "fmt" + "log" + "os" + "testing" + + "github.com/hashicorp/vault/logical" + logicaltest "github.com/hashicorp/vault/logical/testing" + "github.com/mitchellh/mapstructure" +) + +func TestBackend_basic(t *testing.T) { + b, _ := Factory(logical.TestBackendConfig()) + + logicaltest.Test(t, logicaltest.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Backend: b, + Steps: []logicaltest.TestStep{ + testAccStepConfig(t), + testAccStepRole(t), + testAccStepReadCreds(t, "web"), + }, + }) +} + +func TestBackend_roleCrud(t *testing.T) { + b := Backend() + + logicaltest.Test(t, logicaltest.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Backend: b, + Steps: []logicaltest.TestStep{ + testAccStepConfig(t), + testAccStepRole(t), + testAccStepReadRole(t, "web", testRoleSQL), + testAccStepDeleteRole(t, "web"), + testAccStepReadRole(t, "web", ""), + }, + }) +} + +func TestBackend_leaseWriteRead(t *testing.T) { + b := Backend() + + logicaltest.Test(t, logicaltest.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Backend: b, + Steps: []logicaltest.TestStep{ + testAccStepConfig(t), + testAccStepWriteLease(t), + testAccStepReadLease(t), + }, + }) + +} + +func testAccPreCheck(t *testing.T) { + if v := os.Getenv("MSSQL_PARAMS"); v == "" { + t.Fatal("MSSQL_PARAMS must be set for acceptance tests") + } +} + +func testAccStepConfig(t *testing.T) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.UpdateOperation, + Path: "config/connection", + Data: map[string]interface{}{ + "connection_params": os.Getenv("MSSQL_PARAMS"), + }, + } +} + +func testAccStepRole(t *testing.T) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.UpdateOperation, + Path: "roles/web", + Data: map[string]interface{}{ + "sql": testRoleSQL, + }, + } +} + +func testAccStepDeleteRole(t *testing.T, n string) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.DeleteOperation, + Path: "roles/" + n, + } +} + +func testAccStepReadCreds(t *testing.T, name string) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.ReadOperation, + Path: "creds/" + name, + Check: func(resp *logical.Response) error { + var d struct { + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + } + if err := mapstructure.Decode(resp.Data, &d); err != nil { + return err + } + log.Printf("[WARN] Generated credentials: %v", d) + + return nil + }, + } +} + +func testAccStepReadRole(t *testing.T, name, sql string) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.ReadOperation, + Path: "roles/" + name, + Check: func(resp *logical.Response) error { + if resp == nil { + if sql == "" { + return nil + } + + return fmt.Errorf("bad: %#v", resp) + } + + var d struct { + SQL string `mapstructure:"sql"` + } + if err := mapstructure.Decode(resp.Data, &d); err != nil { + return err + } + + if d.SQL != sql { + return fmt.Errorf("bad: %#v", resp) + } + + return nil + }, + } +} + +func testAccStepWriteLease(t *testing.T) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.UpdateOperation, + Path: "config/lease", + Data: map[string]interface{}{ + "lease": "1h5m", + "lease_max": "24h", + }, + } +} + +func testAccStepReadLease(t *testing.T) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.ReadOperation, + Path: "config/lease", + Check: func(resp *logical.Response) error { + if resp.Data["lease"] != "1h5m0s" || resp.Data["lease_max"] != "24h0m0s" { + return fmt.Errorf("bad: %#v", resp) + } + + return nil + }, + } +} + +const testRoleSQL = ` +CREATE LOGIN [{{name}}] WITH PASSWORD = '{{password}}'; +CREATE USER [{{name}}] FOR LOGIN [{{name}}]; +GRANT SELECT ON SCHEMA::dbo TO [{{name}}] +` diff --git a/builtin/logical/mssql/path_config_connection.go b/builtin/logical/mssql/path_config_connection.go new file mode 100644 index 0000000000..ac4c67f558 --- /dev/null +++ b/builtin/logical/mssql/path_config_connection.go @@ -0,0 +1,88 @@ +package mssql + +import ( + "database/sql" + "fmt" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathConfigConnection(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "config/connection", + Fields: map[string]*framework.FieldSchema{ + "connection_params": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "DB connection parameters", + }, + "max_open_connections": &framework.FieldSchema{ + Type: framework.TypeInt, + Description: "Maximum number of open connections to database", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathConnectionWrite, + }, + + HelpSynopsis: pathConfigConnectionHelpSyn, + HelpDescription: pathConfigConnectionHelpDesc, + } +} + +func (b *backend) pathConnectionWrite( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + connParams := data.Get("connection_params").(string) + + maxOpenConns := data.Get("max_open_connections").(int) + if maxOpenConns == 0 { + maxOpenConns = 2 + } + + // Verify the string + db, err := sql.Open("mssql", BuildDsn(connParams)) + + if err != nil { + return logical.ErrorResponse(fmt.Sprintf( + "Error validating connection info: %s", err)), nil + } + defer db.Close() + if err := db.Ping(); err != nil { + return logical.ErrorResponse(fmt.Sprintf( + "Error validating connection info: %s", err)), nil + } + + // Store it + entry, err := logical.StorageEntryJSON("config/connection", connectionConfig{ + ConnectionParams: connParams, + MaxOpenConnections: maxOpenConns, + }) + if err != nil { + return nil, err + } + if err := req.Storage.Put(entry); err != nil { + return nil, err + } + + // Reset the DB connection + b.ResetDB() + return nil, nil +} + +type connectionConfig struct { + ConnectionParams string `json:"connection_params"` + MaxOpenConnections int `json:"max_open_connections"` +} + +const pathConfigConnectionHelpSyn = ` +Configure the connection string to talk to Microsoft Sql Server. +` + +const pathConfigConnectionHelpDesc = ` +This path configures the connection string used to connect to Sql Server. +The value of the string is a Data Source Name (DSN). An example is +using "server=;port=;user id=;password=;database=;" + +When configuring the connection string, the backend will verify its validity. +` diff --git a/builtin/logical/mssql/path_config_lease.go b/builtin/logical/mssql/path_config_lease.go new file mode 100644 index 0000000000..e013c107ca --- /dev/null +++ b/builtin/logical/mssql/path_config_lease.go @@ -0,0 +1,103 @@ +package mssql + +import ( + "fmt" + "time" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathConfigLease(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "config/lease", + Fields: map[string]*framework.FieldSchema{ + "lease": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Default lease for roles.", + }, + + "lease_max": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Maximum time a credential is valid for.", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ReadOperation: b.pathLeaseRead, + logical.UpdateOperation: b.pathLeaseWrite, + }, + + HelpSynopsis: pathConfigLeaseHelpSyn, + HelpDescription: pathConfigLeaseHelpDesc, + } +} + +func (b *backend) pathLeaseWrite( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + leaseRaw := d.Get("lease").(string) + leaseMaxRaw := d.Get("lease_max").(string) + + lease, err := time.ParseDuration(leaseRaw) + if err != nil { + return logical.ErrorResponse(fmt.Sprintf( + "Invalid lease: %s", err)), nil + } + leaseMax, err := time.ParseDuration(leaseMaxRaw) + if err != nil { + return logical.ErrorResponse(fmt.Sprintf( + "Invalid lease: %s", err)), nil + } + + // Store it + entry, err := logical.StorageEntryJSON("config/lease", &configLease{ + Lease: lease, + LeaseMax: leaseMax, + }) + if err != nil { + return nil, err + } + if err := req.Storage.Put(entry); err != nil { + return nil, err + } + + return nil, nil +} + +func (b *backend) pathLeaseRead( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + lease, err := b.Lease(req.Storage) + + if err != nil { + return nil, err + } + if lease == nil { + return nil, nil + } + + return &logical.Response{ + Data: map[string]interface{}{ + "lease": lease.Lease.String(), + "lease_max": lease.LeaseMax.String(), + }, + }, nil +} + +type configLease struct { + Lease time.Duration + LeaseMax time.Duration +} + +const pathConfigLeaseHelpSyn = ` +Configure the default lease information for generated credentials. +` + +const pathConfigLeaseHelpDesc = ` +This configures the default lease information used for credentials +generated by this backend. The lease specifies the duration that a +credential will be valid for, as well as the maximum session for +a set of credentials. + +The format for the lease is "1h" or integer and then unit. The longest +unit is hour. +` diff --git a/builtin/logical/mssql/path_creds_create.go b/builtin/logical/mssql/path_creds_create.go new file mode 100644 index 0000000000..9d518f7ed3 --- /dev/null +++ b/builtin/logical/mssql/path_creds_create.go @@ -0,0 +1,123 @@ +package mssql + +import ( + "fmt" + "time" + + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathCredsCreate(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "creds/" + framework.GenericNameRegex("name"), + Fields: map[string]*framework.FieldSchema{ + "name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ReadOperation: b.pathCredsCreateRead, + }, + + HelpSynopsis: pathCredsCreateHelpSyn, + HelpDescription: pathCredsCreateHelpDesc, + } +} + +func (b *backend) pathCredsCreateRead( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + name := data.Get("name").(string) + + // Get the role + role, err := b.Role(req.Storage, name) + if err != nil { + return nil, err + } + if role == nil { + return logical.ErrorResponse(fmt.Sprintf("unknown role: %s", name)), nil + } + + // Determine if we have a lease + lease, err := b.Lease(req.Storage) + if err != nil { + return nil, err + } + if lease == nil { + lease = &configLease{Lease: 1 * time.Hour} + } + + // Generate our username and password + displayName := req.DisplayName + if len(displayName) > 10 { + displayName = displayName[:10] + } + userUUID, err := uuid.GenerateUUID() + if err != nil { + return nil, err + } + username := fmt.Sprintf("%s-%s", displayName, userUUID) + password, err := uuid.GenerateUUID() + if err != nil { + return nil, err + } + + // Get our connection + db, err := b.DB(req.Storage) + if err != nil { + return nil, err + } + + // Start a transaction + tx, err := db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + + // Always reset database to default db of connection. Since it is in a + // transaction, all statements will be on the same connection in the pool. + roleSQL := fmt.Sprintf("USE [%s]; %s", b.defaultDb, role.SQL) + + // Execute each query + for _, query := range SplitSQL(roleSQL) { + stmt, err := db.Prepare(Query(query, map[string]string{ + "name": username, + "password": password, + })) + if err != nil { + return nil, err + } + if _, err := stmt.Exec(); err != nil { + return nil, err + } + } + + // Commit the transaction + if err := tx.Commit(); err != nil { + return nil, err + } + + // Return the secret + resp := b.Secret(SecretCredsType).Response(map[string]interface{}{ + "username": username, + "password": password, + }, map[string]interface{}{ + "username": username, + }) + resp.Secret.TTL = lease.Lease + return resp, nil +} + +const pathCredsCreateHelpSyn = ` +Request database credentials for a certain role. +` + +const pathCredsCreateHelpDesc = ` +This path reads database credentials for a certain role. The +database credentials will be generated on demand and will be automatically +revoked when the lease is up. +` diff --git a/builtin/logical/mssql/path_roles.go b/builtin/logical/mssql/path_roles.go new file mode 100644 index 0000000000..b238c46369 --- /dev/null +++ b/builtin/logical/mssql/path_roles.go @@ -0,0 +1,167 @@ +package mssql + +import ( + "fmt" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathListRoles(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "roles/?$", + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ListOperation: b.pathRoleList, + }, + + HelpSynopsis: pathRoleHelpSyn, + HelpDescription: pathRoleHelpDesc, + } +} + +func pathRoles(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "roles/" + framework.GenericNameRegex("name"), + Fields: map[string]*framework.FieldSchema{ + "name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + + "sql": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "SQL string to create a role. See help for more info.", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ReadOperation: b.pathRoleRead, + logical.UpdateOperation: b.pathRoleCreate, + logical.DeleteOperation: b.pathRoleDelete, + }, + + HelpSynopsis: pathRoleHelpSyn, + HelpDescription: pathRoleHelpDesc, + } +} + +func (b *backend) Role(s logical.Storage, n string) (*roleEntry, error) { + entry, err := s.Get("role/" + n) + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + + var result roleEntry + if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + + return &result, nil +} + +func (b *backend) pathRoleDelete( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + err := req.Storage.Delete("role/" + data.Get("name").(string)) + if err != nil { + return nil, err + } + + return nil, nil +} + +func (b *backend) pathRoleRead( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + role, err := b.Role(req.Storage, data.Get("name").(string)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + return &logical.Response{ + Data: map[string]interface{}{ + "sql": role.SQL, + }, + }, nil +} + +func (b *backend) pathRoleList( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + entries, err := req.Storage.List("role/") + if err != nil { + return nil, err + } + + return logical.ListResponse(entries), nil +} + +func (b *backend) pathRoleCreate( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + name := data.Get("name").(string) + sql := data.Get("sql").(string) + + // Get our connection + db, err := b.DB(req.Storage) + if err != nil { + return nil, err + } + + // Test the query by trying to prepare it + for _, query := range SplitSQL(sql) { + stmt, err := db.Prepare(Query(query, map[string]string{ + "name": "foo", + })) + if err != nil { + return logical.ErrorResponse(fmt.Sprintf( + "Error testing query: %s", err)), nil + } + stmt.Close() + } + + // Store it + entry, err := logical.StorageEntryJSON("role/"+name, &roleEntry{ + SQL: sql, + }) + if err != nil { + return nil, err + } + if err := req.Storage.Put(entry); err != nil { + return nil, err + } + return nil, nil +} + +type roleEntry struct { + SQL string `json:"sql"` +} + +const pathRoleHelpSyn = ` +Manage the roles that can be created with this backend. +` + +const pathRoleHelpDesc = ` +This path lets you manage the roles that can be created with this backend. + +The "sql" parameter customizes the SQL string used to create the login to +the server. The parameter can be a sequence of SQL queries, each semi-colon +seperated. Some substitution will be done to the SQL string for certain keys. +The names of the variables must be surrounded by "{{" and "}}" to be replaced. + + * "name" - The random username generated for the DB user. + + * "password" - The random password generated for the DB user. + +Example SQL query to use: + + CREATE LOGIN [{{name}}] WITH PASSWORD = '{{password}}'; + CREATE USER [{{name}}] FROM LOGIN [{{name}}]; + GRANT SELECT, UPDATE, DELETE, INSERT on SCHEMA::dbo TO [{{name}}]; + +Please see the Microsoft SQL Server manual on the GRANT command to learn how to +do more fine grained access. +` diff --git a/builtin/logical/mssql/secret_creds.go b/builtin/logical/mssql/secret_creds.go new file mode 100644 index 0000000000..fcb777aede --- /dev/null +++ b/builtin/logical/mssql/secret_creds.go @@ -0,0 +1,173 @@ +package mssql + +import ( + "fmt" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +const SecretCredsType = "creds" + +func secretCreds(b *backend) *framework.Secret { + return &framework.Secret{ + Type: SecretCredsType, + Fields: map[string]*framework.FieldSchema{ + "username": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Username", + }, + + "password": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Password", + }, + }, + + Renew: b.secretCredsRenew, + Revoke: b.secretCredsRevoke, + } +} + +func (b *backend) secretCredsRenew( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + // Get the lease information + lease, err := b.Lease(req.Storage) + if err != nil { + return nil, err + } + if lease == nil { + lease = &configLease{} + } + + f := framework.LeaseExtend(lease.Lease, lease.LeaseMax, b.System()) + return f(req, d) +} + +func (b *backend) secretCredsRevoke( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + // Get the username from the internal data + usernameRaw, ok := req.Secret.InternalData["username"] + if !ok { + return nil, fmt.Errorf("secret is missing username internal data") + } + username, ok := usernameRaw.(string) + + // Get our connection + db, err := b.DB(req.Storage) + if err != nil { + return nil, err + } + + // First disable server login + stmt, err := db.Prepare(fmt.Sprintf("ALTER LOGIN [%s] DISABLE;", username)) + if err != nil { + return nil, err + } + defer stmt.Close() + if _, err := stmt.Exec(); err != nil { + return nil, err + } + + // Query for sessions for the login so that we can kill any outstanding + // sessions. There cannot be any active sessions before we drop the logins + // This isn't done in a transaction because even if we fail along the way, + // we want to remove as much access as possible + stmt, err = db.Prepare(fmt.Sprintf( + "SELECT session_id FROM sys.dm_exec_sessions WHERE login_name = '%s';", username)) + if err != nil { + return nil, err + } + defer stmt.Close() + + rows, err := stmt.Query() + if err != nil { + return nil, err + } + defer rows.Close() + + var revokeStmts []string + for rows.Next() { + var sessionID int + err = rows.Scan(&sessionID) + if err != nil { + continue + } + revokeStmts = append(revokeStmts, fmt.Sprintf("KILL %d;", sessionID)) + } + + // Query for database users using undocumented stored procedure for now since + // it is the easiest way to get this information; + // we need to drop the database users before we can drop the login and the role + // This isn't done in a transaction because even if we fail along the way, + // we want to remove as much access as possible + stmt, err = db.Prepare(fmt.Sprintf("EXEC sp_msloginmappings '%s';", username)) + if err != nil { + return nil, err + } + defer stmt.Close() + + rows, err = stmt.Query() + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var loginName, dbName, qUsername, aliasName string + err = rows.Scan(&loginName, &dbName, &qUsername, &aliasName) + if err != nil { + // keep going; remove as many permissions as possible right now + continue + } + revokeStmts = append(revokeStmts, fmt.Sprintf( + "USE [%s]; DROP USER IF EXISTS [%s];", dbName, username)) + } + + // we do not stop on error, as we want to remove as + // many permissions as possible right now + var lastStmtError error + for _, query := range revokeStmts { + stmt, err := db.Prepare(query) + if err != nil { + lastStmtError = err + continue + } + _, err = stmt.Exec() + if err != nil { + lastStmtError = err + } + } + + // can't drop if not all database users are dropped + if rows.Err() != nil { + return logical.ErrorResponse(fmt.Sprintf( + "could not generate sql statements for all rows: %v", rows.Err())), nil + } + if lastStmtError != nil { + return logical.ErrorResponse(fmt.Sprintf( + "could not perform all sql statements: %v", lastStmtError)), nil + } + + // Drop this login + stmt, err = db.Prepare(fmt.Sprintf(dropLoginSQL, username, username)) + if err != nil { + return nil, err + } + defer stmt.Close() + if _, err := stmt.Exec(); err != nil { + return nil, err + } + + return nil, nil +} + +const dropLoginSQL = ` +IF EXISTS + (SELECT name + FROM master.sys.server_principals + WHERE name = '%s') +BEGIN + DROP LOGIN [%s] +END +` diff --git a/builtin/logical/mssql/util.go b/builtin/logical/mssql/util.go new file mode 100644 index 0000000000..0313a56d5c --- /dev/null +++ b/builtin/logical/mssql/util.go @@ -0,0 +1,60 @@ +package mssql + +import ( + "fmt" + "strings" +) + +// BuildDsn creates a DSN with some default and overriden values +func BuildDsn(dsn string) string { + dsnParts := make(map[string]string) + parts := strings.Split(dsn, ";") + for _, part := range parts { + if len(part) == 0 { + continue + } + lst := strings.SplitN(part, "=", 2) + name := strings.TrimSpace(strings.ToLower(lst[0])) + if len(name) == 0 { + continue + } + var value string + if len(lst) > 1 { + value = strings.TrimSpace(lst[1]) + } + dsnParts[name] = value + } + + // Default app name to vault + if _, exists := dsnParts["app name"]; !exists { + dsnParts["app name"] = "vault" + } + + var newDsn string + for k, v := range dsnParts { + newDsn = newDsn + k + "=" + v + ";" + } + return newDsn +} + +// SplitSQL is used to split a series of SQL statements +func SplitSQL(sql string) []string { + parts := strings.Split(sql, ";") + out := make([]string, 0, len(parts)) + for _, p := range parts { + clean := strings.TrimSpace(p) + if len(clean) > 0 { + out = append(out, clean) + } + } + return out +} + +// Query templates a query for us. +func Query(tpl string, data map[string]string) string { + for k, v := range data { + tpl = strings.Replace(tpl, fmt.Sprintf("{{%s}}", k), v, -1) + } + + return tpl +} diff --git a/cli/commands.go b/cli/commands.go index 05f5c74795..9c403fe348 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/vault/builtin/logical/aws" "github.com/hashicorp/vault/builtin/logical/cassandra" "github.com/hashicorp/vault/builtin/logical/consul" + "github.com/hashicorp/vault/builtin/logical/mssql" "github.com/hashicorp/vault/builtin/logical/mysql" "github.com/hashicorp/vault/builtin/logical/pki" "github.com/hashicorp/vault/builtin/logical/postgresql" @@ -73,6 +74,7 @@ func Commands(metaPtr *command.Meta) map[string]cli.CommandFactory { "cassandra": cassandra.Factory, "pki": pki.Factory, "transit": transit.Factory, + "mssql": mssql.Factory, "mysql": mysql.Factory, "ssh": ssh.Factory, }, diff --git a/website/source/docs/secrets/mssql/index.html.md b/website/source/docs/secrets/mssql/index.html.md new file mode 100644 index 0000000000..dbb8062582 --- /dev/null +++ b/website/source/docs/secrets/mssql/index.html.md @@ -0,0 +1,358 @@ +--- +layout: "docs" +page_title: "Secret Backend: mssql" +sidebar_current: "docs-secrets-mssql" +description: |- + The MSSQL secret backend for Vault generates database credentials to access Microsoft Sql Server. +--- + +# MSSQL Secret Backend + +Name: `mssql` + +The MSSQL secret backend for Vault generates database credentials +dynamically based on configured roles. This means that services that need +to access a database no longer need to hardcode credentials: they can request +them from Vault, and use Vault's leasing mechanism to more easily roll keys. + +Additionally, it introduces a new ability: with every service accessing +the database with unique credentials, it makes auditing much easier when +questionable data access is discovered: you can track it down to the specific +instance of a service based on the SQL username. + +Vault makes use of its own internal revocation system to ensure that users +become invalid within a reasonable time of the lease expiring. + +This page will show a quick start for this backend. For detailed documentation +on every path, use `vault path-help` after mounting the backend. + +## Quick Start + +The first step to using the mssql backend is to mount it. +Unlike the `generic` backend, the `mssql` backend is not mounted by default. + +``` +$ vault mount mssql +Successfully mounted 'mssql' at 'mssql'! +``` + +Next, we must configure Vault to know how to connect to the MSSQL +instance. This is done by providing a DSN (Data Source Name): + +``` +$ vault write mssql/config/connection \ + value="server=localhost;port=1433;user id=sa;password=Password!;database=AdventureWorks;/" +Success! Data written to: mssql/config/connection +``` + +In this case, we've configured Vault with the user "sa" and password "Password!", +connecting to an instance at "localhost" on port 1433. It is not necessary +that Vault has the sa login, but the user must have privileges to create +logins, create users on the database, and manage processes. The server roles +`securityadmin` and `processadmin` are examples of built-in roles that grant +these permissions. + +Optionally, we can configure the lease settings for credentials generated +by Vault. This is done by writing to the `config/lease` key: + +``` +$ vault write mssql/config/lease \ + lease=1h \ + lease_max=24h +Success! Data written to: mssql`/config/lease +``` + +This restricts each credential to being valid or leased for 1 hour +at a time, with a maximum use period of 24 hours. This forces an +application to renew their credentials at least hourly, and to recycle +them once per day. + +The next step is to configure a role. A role is a logical name that maps +to a policy used to generate those credentials. For example, lets create +a "readonly" role: + +``` +$ vault write mssql/roles/readonly \ + sql="CREATE LOGIN [{{name}}] WITH PASSWORD = '{{password}}'; CREATE USER [{{name}}] FOR LOGIN [{{name}}]; GRANT SELECT ON SCHEMA::dbo TO [{{name}}]" +Success! Data written to: mssql/roles/readonly +``` + +By writing to the `roles/readonly` path we are defining the `readonly` role. +This role will be created by evaluating the given `sql` statements. By +default, the `{{name}}` and `{{password}}` fields will be populated by +Vault with dynamically generated values. This SQL statement is creating +the named login on the server and user on the default database for the +connection, and then granting it `SELECT` on the `dbo` schema. More complex +`GRANT` queries can be used to customize the privileges of the role. + +To generate a new set of credentials, we simply read from that role: + +``` +$ vault read mssql/creds/readonly +Key Value +lease_id mssql/creds/readonly/cdf23ac8-6dbd-4bf9-9919-6acaaa86ba6c +lease_duration 3600 +password ee202d0d-e4fd-4410-8d14-2a78c5c8cb76 +username root-a147d529-e7d6-4a16-8930-4c3e72170b19 +``` + +By reading from the `creds/readonly` path, Vault has generated a new +set of credentials using the `readonly` role configuration. Here we +see the dynamically generated username and password, along with a one +hour lease. + +Using ACLs, it is possible to restrict using the mssql backend such +that trusted operators can manage the role definitions, and both +users and applications are restricted in the credentials they are +allowed to read. + +## API + +### /mssql/config/connection +#### POST + +
+
Description
+
+ Configures the connection DSN used to communicate with Sql Server. + This is a root protected endpoint. +
+ +
Method
+
POST
+ +
URL
+
`/mssql/config/connection`
+ +
Parameters
+
+
    +
  • + value + required + The MSSQL DSN +
  • +
+
+
+
    +
  • + max_open_connections + optional + Maximum number of open connections to the database. + Defaults to 2. +
  • +
+
+ +
Returns
+
+ A `204` response code. +
+
+ +### /mssql/config/lease +#### POST + +
+
Description
+
+ Configures the lease settings for generated credentials. + If not configured, leases default to 1 hour. This is a root + protected endpoint. +
+ +
Method
+
POST
+ +
URL
+
`/mssql/config/lease`
+ +
Parameters
+
+
    +
  • + lease + required + The lease value provided as a string duration + with time suffix. Hour is the largest suffix. +
  • +
  • + lease_max + required + The maximum lease value provided as a string duration + with time suffix. Hour is the largest suffix. +
  • +
+
+ +
Returns
+
+ A `204` response code. +
+
+ +### /mssql/roles/ +#### POST + +
+
Description
+
+ Creates or updates the role definition. +
+ +
Method
+
POST
+ +
URL
+
`/mssql/roles/`
+ +
Parameters
+
+
    +
  • + sql + required + The SQL statements executed to create and configure the role. + Must be semi-colon separated. The '{{name}}' and '{{password}}' + values will be substituted. +
  • +
+
+ +
Returns
+
+ A `204` response code. +
+
+ +#### GET + +
+
Description
+
+ Queries the role definition. +
+ +
Method
+
GET
+ +
URL
+
`/mssql/roles/`
+ +
Parameters
+
+ None +
+ +
Returns
+
+ + ```javascript + { + "data": { + "sql": "CREATE LOGIN..." + } + } + ``` + +
+
+ +#### LIST + +
+
Description
+
+ Returns a list of available roles. Only the role names are returned, not + any values. +
+ +
Method
+
GET
+ +
URL
+
`/roles/?list=true`
+ +
Parameters
+
+ None +
+ +
Returns
+
+ + ```javascript + { + "auth": null, + "data": { + "keys": ["dev", "prod"] + }, + "lease_duration": 2592000, + "lease_id": "", + "renewable": false + } + ``` + +
+
+ +#### DELETE + +
+
Description
+
+ Deletes the role definition. +
+ +
Method
+
DELETE
+ +
URL
+
`/mssql/roles/`
+ +
Parameters
+
+ None +
+ +
Returns
+
+ A `204` response code. +
+
+ +### /mssql/creds/ +#### GET + +
+
Description
+
+ Generates a new set of dynamic credentials based on the named role. +
+ +
Method
+
GET
+ +
URL
+
`/mssql/creds/`
+ +
Parameters
+
+ None +
+ +
Returns
+
+ + ```javascript + { + "data": { + "username": "root-a147d529-e7d6-4a16-8930-4c3e72170b19", + "password": "ee202d0d-e4fd-4410-8d14-2a78c5c8cb76" + } + } + ``` + +
+