From 1faaf20b922c4cfe5c91e6cc034c779ebd9b5e6a Mon Sep 17 00:00:00 2001 From: Jeff Mitchell Date: Fri, 19 Jun 2015 13:10:19 -0400 Subject: [PATCH] A Cassandra secrets backend. Supports creation and deletion of users in Cassandra using flexible CQL queries. TLS, including client authentication, is supported. Commit contents (C)2015 Akamai Technologies, Inc. --- builtin/logical/cassandra/backend.go | 109 ++++++ builtin/logical/cassandra/backend_test.go | 128 +++++++ .../cassandra/path_config_connection.go | 209 +++++++++++ .../logical/cassandra/path_creds_create.go | 95 +++++ builtin/logical/cassandra/path_roles.go | 199 +++++++++++ builtin/logical/cassandra/secret_creds.go | 85 +++++ builtin/logical/cassandra/util.go | 106 ++++++ cli/commands.go | 2 + .../docs/secrets/cassandra/index.html.md | 332 ++++++++++++++++++ website/source/layouts/docs.erb | 4 + 10 files changed, 1269 insertions(+) create mode 100644 builtin/logical/cassandra/backend.go create mode 100644 builtin/logical/cassandra/backend_test.go create mode 100644 builtin/logical/cassandra/path_config_connection.go create mode 100644 builtin/logical/cassandra/path_creds_create.go create mode 100644 builtin/logical/cassandra/path_roles.go create mode 100644 builtin/logical/cassandra/secret_creds.go create mode 100644 builtin/logical/cassandra/util.go create mode 100644 website/source/docs/secrets/cassandra/index.html.md diff --git a/builtin/logical/cassandra/backend.go b/builtin/logical/cassandra/backend.go new file mode 100644 index 0000000000..65480982fe --- /dev/null +++ b/builtin/logical/cassandra/backend.go @@ -0,0 +1,109 @@ +package cassandra + +import ( + "fmt" + "strings" + "sync" + + "github.com/gocql/gocql" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +// Factory creates a new backend +func Factory(map[string]string) (logical.Backend, error) { + return Backend(), nil +} + +// Backend contains the base information for the backend's functionality +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), + pathRoles(&b), + pathCredsCreate(&b), + }, + + Secrets: []*framework.Secret{ + secretCreds(&b), + }, + } + + return b.Backend +} + +type backend struct { + *framework.Backend + + // Session is goroutine safe, however, since we reinitialize + // it when connection info changes, we want to make sure we + // can close it and use a new connection; hence the lock + session *gocql.Session + lock sync.Mutex +} + +type sessionConfig struct { + Hosts string `json:"hosts" structs:"hosts"` + Username string `json:"username" structs:"username"` + Password string `json:"password" structs:"password"` + TLS bool `json:"tls" structs:"tls"` + InsecureTLS bool `json:"insecure_tls" structs:"insecure_tls"` + Certificate string `json:"certificate" structs:"certificate"` + PrivateKey string `json:"private_key" structs:"private_key"` + IssuingCA string `json:"issuing_ca" structs:"issuing_ca"` +} + +// DB returns the database connection. +func (b *backend) DB(s logical.Storage) (*gocql.Session, error) { + b.lock.Lock() + defer b.lock.Unlock() + + // If we already have a DB, we got it! + if b.session != nil { + return b.session, nil + } + + 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") + } + + config := &sessionConfig{} + if err := entry.DecodeJSON(config); err != nil { + return nil, err + } + + return createSession(config, s) +} + +// ResetDB forces a connection next time DB() is called. +func (b *backend) ResetDB(newSession *gocql.Session) { + b.lock.Lock() + defer b.lock.Unlock() + + if b.session != nil { + b.session.Close() + } + + b.session = newSession +} + +const backendHelp = ` +The Cassandra backend dynamically generates database users. + +After mounting this backend, configure it using the endpoints within +the "config/" path. +` diff --git a/builtin/logical/cassandra/backend_test.go b/builtin/logical/cassandra/backend_test.go new file mode 100644 index 0000000000..1ce8811c6b --- /dev/null +++ b/builtin/logical/cassandra/backend_test.go @@ -0,0 +1,128 @@ +package cassandra + +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 := Backend() + + logicaltest.Test(t, logicaltest.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Backend: b, + Steps: []logicaltest.TestStep{ + testAccStepConfig(t), + testAccStepRole(t), + testAccStepReadCreds(t, "test"), + }, + }) +} + +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, "test", testRole), + testAccStepDeleteRole(t, "test"), + testAccStepReadRole(t, "test", ""), + }, + }) +} + +func testAccPreCheck(t *testing.T) { + if v := os.Getenv("CASSANDRA_HOST"); v == "" { + t.Fatal("CASSANDRA_HOST must be set for acceptance tests") + } +} + +func testAccStepConfig(t *testing.T) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.WriteOperation, + Path: "config/connection", + Data: map[string]interface{}{ + "hosts": os.Getenv("CASSANDRA_HOST"), + "username": "cassandra", + "password": "cassandra", + }, + } +} + +func testAccStepRole(t *testing.T) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.WriteOperation, + Path: "roles/test", + Data: map[string]interface{}{ + "creation_cql": testRole, + }, + } +} + +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 string, cql string) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.ReadOperation, + Path: "roles/" + name, + Check: func(resp *logical.Response) error { + if resp == nil { + if cql == "" { + return nil + } + + return fmt.Errorf("response is nil") + } + + var d struct { + CreationCQL string `mapstructure:"creation_cql"` + } + if err := mapstructure.Decode(resp.Data, &d); err != nil { + return err + } + + if d.CreationCQL != cql { + return fmt.Errorf("bad: %#v\n%#v\n%#v\n", resp, cql, d.CreationCQL) + } + + return nil + }, + } +} + +const testRole = `CREATE USER '{{username}}' WITH PASSWORD '{{password}}' NOSUPERUSER; +GRANT ALL PERMISSIONS ON ALL KEYSPACES TO '{{username}}';` diff --git a/builtin/logical/cassandra/path_config_connection.go b/builtin/logical/cassandra/path_config_connection.go new file mode 100644 index 0000000000..1c485b6f49 --- /dev/null +++ b/builtin/logical/cassandra/path_config_connection.go @@ -0,0 +1,209 @@ +package cassandra + +import ( + "fmt" + + "github.com/fatih/structs" + "github.com/hashicorp/vault/helper/certutil" + "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{ + "hosts": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Comma-separated list of hosts", + }, + + "username": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "The username to use for connecting to the cluster", + }, + + "password": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "The password to use for connecting to the cluster", + }, + + "tls": &framework.FieldSchema{ + Type: framework.TypeBool, + Description: `Whether to use TLS. If pem_bundle or pem_json are +set, this is automatically set to true`, + }, + + "insecure_tls": &framework.FieldSchema{ + Type: framework.TypeBool, + Description: `Whether to use TLS but skip verification; has no +effect if a CA certificate is provided`, + }, + + "pem_bundle": &framework.FieldSchema{ + Type: framework.TypeString, + Description: `PEM-format, concatenated unencrypted secret key +and certificate, with optional CA certificate`, + }, + + "pem_json": &framework.FieldSchema{ + Type: framework.TypeString, + Description: `JSON containing a PEM-format, unencrypted secret +key and certificate, with optional CA certificate. +The JSON output of a certificate issued with the PKI +backend can be directly passed into this parameter. +If both this and "pem_bundle" are specified, this will +take precedence.`, + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ReadOperation: b.pathConnectionRead, + logical.WriteOperation: b.pathConnectionWrite, + }, + + HelpSynopsis: pathConfigConnectionHelpSyn, + HelpDescription: pathConfigConnectionHelpDesc, + } +} + +func (b *backend) pathConnectionRead( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + entry, err := req.Storage.Get("config/connection") + if err != nil { + return nil, err + } + if entry == nil { + return logical.ErrorResponse(fmt.Sprintf("Configure the DB connection with config/connection first")), nil + } + + config := &sessionConfig{} + if err := entry.DecodeJSON(config); err != nil { + return nil, err + } + + config.Password = "**********" + if len(config.PrivateKey) > 0 { + config.PrivateKey = "**********" + } + + return &logical.Response{ + Data: structs.New(config).Map(), + }, nil +} + +func (b *backend) pathConnectionWrite( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + hosts := data.Get("hosts").(string) + username := data.Get("username").(string) + password := data.Get("password").(string) + + switch { + case len(hosts) == 0: + return logical.ErrorResponse("Hosts cannot be empty"), nil + case len(username) == 0: + return logical.ErrorResponse("Username cannot be empty"), nil + case len(password) == 0: + return logical.ErrorResponse("Password cannot be empty"), nil + } + + config := &sessionConfig{ + Hosts: hosts, + Username: username, + Password: password, + TLS: data.Get("tls").(bool), + InsecureTLS: data.Get("insecure_tls").(bool), + } + + if config.InsecureTLS { + config.TLS = true + } + + pemBundle := data.Get("pem_bundle").(string) + pemJSON := data.Get("pem_json").(string) + + var certBundle *certutil.CertBundle + var parsedCertBundle *certutil.ParsedCertBundle + var err error + + switch { + case len(pemJSON) != 0: + parsedCertBundle, err = certutil.ParsePKIJSON([]byte(pemJSON)) + if err != nil { + return logical.ErrorResponse(fmt.Sprintf("Could not parse given JSON; it must be in the format of the output of the PKI backend certificate issuing command: %s", err)), nil + } + certBundle, err = parsedCertBundle.ToCertBundle() + if err != nil { + return logical.ErrorResponse(fmt.Sprintf("Error marshaling PEM information: %s", err)), nil + } + config.Certificate = certBundle.Certificate + config.PrivateKey = certBundle.PrivateKey + config.IssuingCA = certBundle.IssuingCA + config.TLS = true + + case len(pemBundle) != 0: + parsedCertBundle, err = certutil.ParsePEMBundle(pemBundle) + if err != nil { + return logical.ErrorResponse(fmt.Sprintf("Error parsing the given PEM information: %s", err)), nil + } + certBundle, err = parsedCertBundle.ToCertBundle() + if err != nil { + return logical.ErrorResponse(fmt.Sprintf("Error marshaling PEM information: %s", err)), nil + } + config.Certificate = certBundle.Certificate + config.PrivateKey = certBundle.PrivateKey + config.IssuingCA = certBundle.IssuingCA + config.TLS = true + } + + session, err := createSession(config, req.Storage) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + + // Store it + entry, err := logical.StorageEntryJSON("config/connection", config) + if err != nil { + return nil, err + } + if err := req.Storage.Put(entry); err != nil { + return nil, err + } + + // Reset the DB connection + b.ResetDB(session) + + return nil, nil +} + +const pathConfigConnectionHelpSyn = ` +Configure the connection information to talk to Cassandra. +` + +const pathConfigConnectionHelpDesc = ` +This path configures the connection information used to connect to Cassandra. + +"hosts" is a comma-deliniated list of hostnames in the Cassandra cluster. + +"username" and "password" are self-explanatory, although the given user +must have superuser access within Cassandra. Note that since this backend +issues username/password credentials, Cassandra must be configured to use +PasswordAuthenticator or a similar backend for its authentication. If you wish +to have no authorization in Cassandra and want to use TLS client certificates, +see the PKI backend. + +TLS works as follows: + +* If "tls" is set to true, the connection will use TLS; this happens automatically if "pem_bundle", "pem_json", or "insecure_tls" is set + +* If "insecure_tls" is set to true, the connection will not perform verification of the server certificate; this also sets "tls" to true + +* If only "issuing_ca" is set in "pem_json", or the only certificate in "pem_bundle" is a CA certificate, the given CA certificate will be used for server certificate verification; otherwise the system CA certificates will be used + +* If "certificate" and "private_key" are set in "pem_bundle" or "pem_json", client auth will be turned on for the connection + +"pem_bundle" should be a PEM-concatenated bundle of a private key + client certificate, an issuing CA certificate, or both. "pem_json" should contain the same information; for convenience, the JSON format is the same as that output by the issue command from the PKI backend. + +When configuring the connection information, the backend will verify its +validity. +` diff --git a/builtin/logical/cassandra/path_creds_create.go b/builtin/logical/cassandra/path_creds_create.go new file mode 100644 index 0000000000..8889b37723 --- /dev/null +++ b/builtin/logical/cassandra/path_creds_create.go @@ -0,0 +1,95 @@ +package cassandra + +import ( + "fmt" + "math/rand" + "time" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathCredsCreate(b *backend) *framework.Path { + return &framework.Path{ + Pattern: `creds/(?P\w+)`, + 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: pathCredsCreateReadHelpSyn, + HelpDescription: pathCredsCreateReadHelpDesc, + } +} + +func (b *backend) pathCredsCreateRead( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + name := data.Get("name").(string) + + // Get the role + role, err := getRole(req.Storage, name) + if err != nil { + return nil, err + } + if role == nil { + return logical.ErrorResponse(fmt.Sprintf("Unknown role: %s", name)), nil + } + + // Generate the username, password and expiration + username := fmt.Sprintf( + "vault-%s-%d-%d", + req.DisplayName, time.Now().Unix(), rand.Int31n(10000)) + password := generateUUID() + + // Get our connection + session, err := b.DB(req.Storage) + if err != nil { + return nil, err + } + + // Execute each query + for _, query := range splitSQL(role.CreationCQL) { + err = session.Query(substQuery(query, map[string]string{ + "username": username, + "password": password, + })).Exec() + if err != nil { + for _, query := range splitSQL(role.RollbackCQL) { + session.Query(substQuery(query, map[string]string{ + "username": username, + "password": password, + })).Exec() + } + return nil, err + } + } + + // Return the secret + resp := b.Secret(SecretCredsType).Response(map[string]interface{}{ + "username": username, + "password": password, + }, map[string]interface{}{ + "username": username, + "role": name, + }) + resp.Secret.Lease = role.Lease + resp.Secret.LeaseGracePeriod = role.LeaseGracePeriod + + return resp, nil +} + +const pathCredsCreateReadHelpSyn = ` +Request database credentials for a certain role. +` + +const pathCredsCreateReadHelpDesc = ` +This path creates 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/cassandra/path_roles.go b/builtin/logical/cassandra/path_roles.go new file mode 100644 index 0000000000..45868748ee --- /dev/null +++ b/builtin/logical/cassandra/path_roles.go @@ -0,0 +1,199 @@ +package cassandra + +import ( + "fmt" + "time" + + "github.com/fatih/structs" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +const ( + defaultCreationCQL = `CREATE USER '{{username}}' WITH PASSWORD '{{password}}' NOSUPERUSER;` + defaultRollbackCQL = `DROP USER '{{username}}';` +) + +func pathRoles(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "roles/(?P\\w+)", + Fields: map[string]*framework.FieldSchema{ + "name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role", + }, + + "creation_cql": &framework.FieldSchema{ + Type: framework.TypeString, + Default: defaultCreationCQL, + Description: `CQL to create a user and optionally grant +authorization. If not supplied, a default that +creates non-superuser accounts with the built-in +password authenticator will be used; no +authorization grants will be configured. Separate +statements by semicolons; use @file to load from a +file. Valid template values are '{{username}}' and +'{{password}}' -- the single quotes are important!`, + }, + + "rollback_cql": &framework.FieldSchema{ + Type: framework.TypeString, + Default: defaultRollbackCQL, + Description: `CQL to roll back an account operation. This will +be used if there is an error during execution of a +statement passed in via the "creation_cql" parameter +parameter. The default simply drops the user, which +should generally be sufficient. Separate statements +by semicolons; use @file to load from a file. Valid +template values are '{{username}}' and +'{{password}}' -- the single quotes are important!`, + }, + + "lease": &framework.FieldSchema{ + Type: framework.TypeString, + Default: "4h", + Description: "The lease length; defaults to 4 hours", + }, + + "lease_grace_period": &framework.FieldSchema{ + Type: framework.TypeString, + Default: "1h", + Description: `Grace period for secret renewal; defaults to +one hour`, + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ReadOperation: b.pathRoleRead, + logical.WriteOperation: b.pathRoleCreate, + logical.DeleteOperation: b.pathRoleDelete, + }, + + HelpSynopsis: pathRoleHelpSyn, + HelpDescription: pathRoleHelpDesc, + } +} + +func getRole(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 := getRole(req.Storage, data.Get("name").(string)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + return &logical.Response{ + Data: structs.New(role).Map(), + }, nil +} + +func (b *backend) pathRoleCreate( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + name := data.Get("name").(string) + + creationCQL := data.Get("creation_cql").(string) + + rollbackCQL := data.Get("rollback_cql").(string) + + leaseRaw := data.Get("lease").(string) + lease, err := time.ParseDuration(leaseRaw) + if err != nil { + return logical.ErrorResponse(fmt.Sprintf( + "Error parsing lease value of %s: %s", leaseRaw, err)), nil + } + + leaseGracePeriodRaw := data.Get("lease_grace_period").(string) + leaseGracePeriod, err := time.ParseDuration(leaseGracePeriodRaw) + if err != nil { + return logical.ErrorResponse(fmt.Sprintf( + "Error parsing lease_grace value of %s: %s", leaseGracePeriodRaw, err)), nil + } + + entry := &roleEntry{ + Lease: lease, + LeaseGracePeriod: leaseGracePeriod, + CreationCQL: creationCQL, + RollbackCQL: rollbackCQL, + } + + // Store it + entryJSON, err := logical.StorageEntryJSON("role/"+name, entry) + if err != nil { + return nil, err + } + if err := req.Storage.Put(entryJSON); err != nil { + return nil, err + } + + return nil, nil +} + +type roleEntry struct { + CreationCQL string `json:"creation_cql" structs:"creation_cql"` + Lease time.Duration `json:"lease" structs:"lease"` + LeaseGracePeriod time.Duration `json:"lease_grace_period" structs:"lease_grace_period"` + RollbackCQL string `json:"rollback_cql" structs:"rollback_cql"` +} + +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 "creation_cql" parameter customizes the CQL string used to create users +and assign them grants. This can be a sequence of CQL queries separated by +semicolons. Some substitution will be done to the CQL string for certain keys. +The names of the variables must be surrounded by '{{' and '}}' to be replaced. +Note that it is important that single quotes are used, not double quotes. + + * "username" - The random username generated for the DB user. + + * "password" - The random password generated for the DB user. + +If no "creation_cql" parameter is given, a default will be used: + +` + defaultCreationCQL + ` + +This default should be suitable for Cassandra installations using the password +authenticator but not configured to use authorization. + +Similarly, the "rollback_cql" is used if user creation fails, in the absense of +Cassandra transactions. The default should be suitable for almost any +instance of Cassandra: + +` + defaultRollbackCQL + ` + +"lease" and "lease_grace_period" control the lease time and the allowed grace +period past lease expiration, respectively. +` diff --git a/builtin/logical/cassandra/secret_creds.go b/builtin/logical/cassandra/secret_creds.go new file mode 100644 index 0000000000..764e42d955 --- /dev/null +++ b/builtin/logical/cassandra/secret_creds.go @@ -0,0 +1,85 @@ +package cassandra + +import ( + "fmt" + "time" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +// SecretCredsType is the type of creds issued from this backend +const SecretCredsType = "cassandra" + +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", + }, + }, + + DefaultDuration: 1 * time.Hour, + DefaultGracePeriod: 10 * time.Minute, + + Renew: b.secretCredsRenew, + Revoke: b.secretCredsRevoke, + } +} + +func (b *backend) secretCredsRenew( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + // Get the lease information + roleRaw, ok := req.Secret.InternalData["role"] + if !ok { + return nil, fmt.Errorf("Secret is missing role internal data") + } + roleName, ok := roleRaw.(string) + if !ok { + return nil, fmt.Errorf("Error converting role internal data to string") + } + + role, err := getRole(req.Storage, roleName) + if err != nil { + return nil, fmt.Errorf("Unable to load role: %s", err) + } + + return framework.LeaseExtend(role.Lease, 0, false)(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) + if !ok { + return nil, fmt.Errorf("Error converting username internal data to string") + } + + session, err := b.DB(req.Storage) + if err != nil { + return nil, fmt.Errorf("Error getting session") + } + + err = session.Query(fmt.Sprintf("REVOKE ALL PERMISSIONS ON ALL KEYSPACES FROM '%s'", username)).Exec() + if err != nil { + return nil, fmt.Errorf("Error revoking permissions for user %s", username) + } + + err = session.Query(fmt.Sprintf("DROP USER '%s'", username)).Exec() + if err != nil { + return nil, fmt.Errorf("Error removing user %s", username) + } + + return nil, nil +} diff --git a/builtin/logical/cassandra/util.go b/builtin/logical/cassandra/util.go new file mode 100644 index 0000000000..ae42f9bb51 --- /dev/null +++ b/builtin/logical/cassandra/util.go @@ -0,0 +1,106 @@ +package cassandra + +import ( + "crypto/rand" + "crypto/tls" + "fmt" + "strings" + + "github.com/gocql/gocql" + "github.com/hashicorp/vault/helper/certutil" + "github.com/hashicorp/vault/logical" +) + +// 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 substQuery(tpl string, data map[string]string) string { + for k, v := range data { + tpl = strings.Replace(tpl, fmt.Sprintf("{{%s}}", k), v, -1) + } + + return tpl +} + +func createSession(cfg *sessionConfig, s logical.Storage) (*gocql.Session, error) { + clusterConfig := gocql.NewCluster(strings.Split(cfg.Hosts, ",")...) + clusterConfig.Authenticator = gocql.PasswordAuthenticator{ + Username: cfg.Username, + Password: cfg.Password, + } + + if cfg.TLS { + tlsConfig := &tls.Config{ + InsecureSkipVerify: cfg.InsecureTLS, + } + + if len(cfg.Certificate) > 0 || len(cfg.IssuingCA) > 0 { + if len(cfg.Certificate) > 0 && len(cfg.PrivateKey) == 0 { + return nil, fmt.Errorf("Found certificate for TLS authentication but no private key") + } + + certBundle := &certutil.CertBundle{} + if len(cfg.Certificate) > 0 { + certBundle.Certificate = cfg.Certificate + certBundle.PrivateKey = cfg.PrivateKey + } + if len(cfg.IssuingCA) > 0 { + certBundle.IssuingCA = cfg.IssuingCA + tlsConfig.InsecureSkipVerify = false + } + + parsedCertBundle, err := certBundle.ToParsedCertBundle() + if err != nil { + return nil, fmt.Errorf("Error parsing certificate bundle: %s", err) + } + + tlsConfig, err = parsedCertBundle.GetTLSConfig(certutil.TLSClient) + if err != nil { + return nil, fmt.Errorf("Error getting TLS configuration: %s", err) + } + } + + clusterConfig.SslOpts = &gocql.SslOptions{ + Config: *tlsConfig, + } + } + + session, err := clusterConfig.CreateSession() + if err != nil { + return nil, fmt.Errorf("Error creating session: %s", err) + } + + // Verify the info + err = session.Query(`LIST USERS`).Exec() + if err != nil { + return nil, fmt.Errorf("Error validating connection info: %s", err) + } + + return session, nil +} + +// generateUUID is used to generate a random UUID +func generateUUID() string { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + panic(fmt.Errorf("failed to read random bytes: %v", err)) + } + + return fmt.Sprintf("%08x-%04x-%04x-%04x-%12x", + buf[0:4], + buf[4:6], + buf[6:8], + buf[8:10], + buf[10:16]) +} diff --git a/cli/commands.go b/cli/commands.go index d115f4a4fb..4385dd3b43 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -15,6 +15,7 @@ import ( credUserpass "github.com/hashicorp/vault/builtin/credential/userpass" "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/mysql" "github.com/hashicorp/vault/builtin/logical/pki" @@ -68,6 +69,7 @@ func Commands(metaPtr *command.Meta) map[string]cli.CommandFactory { "aws": aws.Factory, "consul": consul.Factory, "postgresql": postgresql.Factory, + "cassandra": cassandra.Factory, "pki": pki.Factory, "transit": transit.Factory, "mysql": mysql.Factory, diff --git a/website/source/docs/secrets/cassandra/index.html.md b/website/source/docs/secrets/cassandra/index.html.md new file mode 100644 index 0000000000..b421bbad78 --- /dev/null +++ b/website/source/docs/secrets/cassandra/index.html.md @@ -0,0 +1,332 @@ +--- +layout: "docs" +page_title: "Secret Backend: Cassandra" +sidebar_current: "docs-secrets-cassandra" +description: |- + The Cassandra secret backend for Vault generates database credentials to access Cassandra. +--- + +# Cassandra Secret Backend + +Name: `cassandra` + +The Cassandra 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 Cassandra username. + +This page will show a quick start for this backend. For detailed documentation +on every path, use `vault help` after mounting the backend. + +## Quick Start + +The first step to using the Cassandra backend is to mount it. +Unlike the `generic` backend, the `cassandra` backend is not mounted by default. + +```text +$ vault mount cassandra +Successfully mounted 'cassandra' at 'cassandra'! +``` + +Next, Vault must be configured to connect to Cassandra. This is done by +writing one or more hosts, a username, and a password: + +```text +$ vault write cassandra/config/connection \ + host=localhost username=cassandra password=cassandra +``` + +In this case, we've configured Vault with the user "cassandra" and password "cassandra", +It is important that the Vault user is a superuser, in order to manage other user accounts. + +The next step is to configure a role. A role is a logical name that maps +to a policy used to generated those credentials. For example, lets create +a "readonly" role: + +```text +$ vault write cassandra/roles/readonly \ + creation_cql="CREATE USER '{{username}}' WITH PASSWORD '{{password}}' NOSUPERUSER; \ + GRANT SELECT ON ALL KEYSPACES TO '{{username}}';" +Success! Data written to: cassandra/roles/readonly +``` + +By writing to the `roles/readonly` path we are defining the `readonly` role. +This role will be created by evaluating the given `creation_cql` statements. By +default, the `{{username}}` and `{{password}}` fields will be populated by +Vault with dynamically generated values. This CQL statement is creating +the named user, and then granting it `SELECT` or read-only privileges +to keyspaces. More complex `GRANT` queries can be used to +customize the privileges of the role. See the [CQL Reference Manual](http://docs.datastax.com/en/cql/3.1/cql/cql_reference/grant_r.html) +for more information. + +To generate a new set of credentials, we simply read from that role: +Vault is now configured to create and manage credentials for Cassandra! + +```text +$ vault read cassandra/creds/readonly +Key Value +lease_id cassandra/creds/test/7a23e890-3a26-531d-529b-92d18d1fa63f +lease_duration 3600 +lease_renewable true +password dfa80eea-ccbe-b228-ebf7-e2f62b245e71 +username vault-root-1434647667-9313 +``` + +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 `cassandra` 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. + +If you get stuck at any time, simply run `vault help cassandra` or with a +subpath for interactive help output. + +## API + +### /cassandra/config/connection +#### POST + +
+
Description
+
+ Configures the connection information used to communicate with Cassandra. + TLS works as follows:

+
    +
  • + • If `tls` is set to true, the connection will use TLS; this happens automatically if `pem_bundle`, `pem_json`, or `insecure_tls` is set +
  • +
  • + • If `insecure_tls` is set to true, the connection will not perform verification of the server certificate; this also sets `tls` to true +
  • +
  • + • If only `issuing_ca` is set in `pem_json`, or the only certificate in `pem_bundle` is a CA certificate, the given CA certificate will be used for server certificate verification; otherwise the system CA certificates will be used +
  • +
  • + • If `certificate` and `private_key` are set in `pem_bundle` or `pem_json`, client auth will be turned on for the connection +
  • +
+ `pem_bundle` should be a PEM-concatenated bundle of a private key + client certificate, an issuing CA certificate, or both. `pem_json` should contain the same information; for convenience, the JSON format is the same as that output by the issue command from the PKI backend.

+ This is a root protected endpoint. +
+ +
Method
+
POST
+ +
URL
+
`/cassandra/config/connection`
+ +
Parameters
+
+
    +
  • + hosts + required + A set of comma-deliniated Cassandra hosts to connect to. +
  • +
  • + username + required + The username to use for superuser access. +
  • +
  • + password + required + The password corresponding to the given username. +
  • +
  • + tls + optional + Whether to use TLS when connecting to Cassandra. +
  • +
  • + insecure_tls + optional + Whether to skip verification of the server certificate when using TLS. +
  • +
  • + pem_bundle + optional + Concatenated PEM blocks containing a certificate and private key; + a certificate, private key, and issuing CA certificate; or just a CA + certificate. +
  • +
  • + pem_json + optional + JSON containing a certificate and private key; + a certificate, private key, and issuing CA certificate; or just a CA + certificate. For convenience format is the same as the output of the + `issue` command from the `pki` backend; see [the pki documentation](https://vaultproject.io/docs/secrets/pki/index.html). +
  • +
+
+ +
Returns
+
+ A `204` response code. +
+
+ +### /cassandra/roles/ +#### POST + +
+
Description
+
+ Creates or updates the role definition. +
+ +
Method
+
POST
+ +
URL
+
`/cassandra/roles/`
+ +
Parameters
+
+
    +
  • + creation_cql + optional + The CQL statements executed to create and configure the new user. + Must be semi-colon separated. The '{{username}}' and '{{password}}' + values will be substituted; it is required that these parameters are + in single quotes. The default creates a non-superuser user with + no authorization grants. +
  • +
  • + rollback_cql + optional + The CQL statements executed to attempt a rollback if an error is + encountered during user creation. The default is to delete the user. + Must be semi-colon separated. The '{{username}}' and '{{password}}' + values will be substituted; it is required that these parameters are + in single quotes. +
  • +
  • + lease + optional + The lease value provided as a string duration + with time suffix. Hour is the largest suffix. +
  • +
  • + lease_grace_period + optional + The lease grace period (time before revocation after the lease has + expired) provided as a string duration with time suffix. Hour is the + largest suffix. +
  • +
+
+ +
Returns
+
+ A `204` response code. +
+
+ +#### GET + +
+
Description
+
+ Queries the role definition. +
+ +
Method
+
GET
+ +
URL
+
`/cassandra/roles/`
+ +
Parameters
+
+ None +
+ +
Returns
+
+ + ```javascript + { + "data": { + "creation_cql": "CREATE USER...", + "revocation_cql": "DROP USER...", + "lease": "12h", + "lease_grace_period": "1h" + } + } + ``` + +
+
+ + +#### DELETE + +
+
Description
+
+ Deletes the role definition. +
+ +
Method
+
DELETE
+ +
URL
+
`/cassandra/roles/`
+ +
Parameters
+
+ None +
+ +
Returns
+
+ A `204` response code. +
+
+ +### /cassandra/creds/ +#### GET + +
+
Description
+
+ Generates a new set of dynamic credentials based on the named role. +
+ +
Method
+
GET
+ +
URL
+
`/cassandra/creds/`
+ +
Parameters
+
+ None +
+ +
Returns
+
+ + ```javascript + { + "data": { + "username": "vault-root-1430158508-126", + "password": "132ae3ef-5a64-7499-351e-bfe59f3a2a21" + } + } + ``` + +
+
diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index f4a31be867..b87373f6ba 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -118,6 +118,10 @@ MySQL + > + Cassandra + + > Transit