Postgres revocation sql, beta mode (#1972)

This commit is contained in:
Jeff Mitchell
2016-10-05 13:52:59 -04:00
committed by GitHub
parent 5394fc77e0
commit 37df43d534
8 changed files with 263 additions and 128 deletions

View File

@@ -262,7 +262,7 @@ func testAccStepRole(t *testing.T, wildCard bool) logicaltest.TestStep {
} else { } else {
pathData = map[string]interface{}{ pathData = map[string]interface{}{
"sql": testRoleHost, "sql": testRoleHost,
"revoke_sql": testRevokeSQL, "revocation_sql": testRevocationSQL,
} }
} }
@@ -362,7 +362,7 @@ const testRoleHost = `
CREATE USER '{{name}}'@'10.1.1.2' IDENTIFIED BY '{{password}}'; CREATE USER '{{name}}'@'10.1.1.2' IDENTIFIED BY '{{password}}';
GRANT SELECT ON *.* TO '{{name}}'@'10.1.1.2'; GRANT SELECT ON *.* TO '{{name}}'@'10.1.1.2';
` `
const testRevokeSQL = ` const testRevocationSQL = `
REVOKE ALL PRIVILEGES, GRANT OPTION FROM '{{name}}'@'10.1.1.2'; REVOKE ALL PRIVILEGES, GRANT OPTION FROM '{{name}}'@'10.1.1.2';
DROP USER '{{name}}'@'10.1.1.2'; DROP USER '{{name}}'@'10.1.1.2';
` `

View File

@@ -37,7 +37,7 @@ func pathRoles(b *backend) *framework.Path {
Description: "SQL string to create a user. See help for more info.", Description: "SQL string to create a user. See help for more info.",
}, },
"revoke_sql": { "revocation_sql": {
Type: framework.TypeString, Type: framework.TypeString,
Description: "SQL string to revoke a user. See help for more info.", Description: "SQL string to revoke a user. See help for more info.",
}, },
@@ -118,7 +118,7 @@ func (b *backend) pathRoleRead(
return &logical.Response{ return &logical.Response{
Data: map[string]interface{}{ Data: map[string]interface{}{
"sql": role.SQL, "sql": role.SQL,
"revoke_sql": role.RevokeSQL, "revocation_sql": role.RevocationSQL,
}, },
}, nil }, nil
} }
@@ -165,7 +165,7 @@ func (b *backend) pathRoleCreate(
// Store it // Store it
entry, err := logical.StorageEntryJSON("role/"+name, &roleEntry{ entry, err := logical.StorageEntryJSON("role/"+name, &roleEntry{
SQL: sql, SQL: sql,
RevokeSQL: data.Get("revoke_sql").(string), RevocationSQL: data.Get("revocation_sql").(string),
UsernameLength: data.Get("username_length").(int), UsernameLength: data.Get("username_length").(int),
DisplaynameLength: data.Get("displayname_length").(int), DisplaynameLength: data.Get("displayname_length").(int),
RolenameLength: data.Get("rolename_length").(int), RolenameLength: data.Get("rolename_length").(int),
@@ -181,7 +181,7 @@ func (b *backend) pathRoleCreate(
type roleEntry struct { type roleEntry struct {
SQL string `json:"sql" mapstructure:"sql" structs:"sql"` SQL string `json:"sql" mapstructure:"sql" structs:"sql"`
RevokeSQL string `json:"revoke_sql" mapstructure:"revoke_sql" structs:"revoke_sql"` RevocationSQL string `json:"revocation_sql" mapstructure:"revocation_sql" structs:"revocation_sql"`
UsernameLength int `json:"username_length" mapstructure:"username_length" structs:"username_length"` UsernameLength int `json:"username_length" mapstructure:"username_length" structs:"username_length"`
DisplaynameLength int `json:"displayname_length" mapstructure:"displayname_length" structs:"displayname_length"` DisplaynameLength int `json:"displayname_length" mapstructure:"displayname_length" structs:"displayname_length"`
RolenameLength int `json:"rolename_length" mapstructure:"rolename_length" structs:"rolename_length"` RolenameLength int `json:"rolename_length" mapstructure:"rolename_length" structs:"rolename_length"`

View File

@@ -11,12 +11,12 @@ import (
const SecretCredsType = "creds" const SecretCredsType = "creds"
// defaultRevokeSQL is a default SQL statement for revoking a user. Revoking // defaultRevocationSQL is a default SQL statement for revoking a user. Revoking
// permissions for the user is done before the drop, because MySQL explicitly // permissions for the user is done before the drop, because MySQL explicitly
// documents that open user connections will not be closed. By revoking all // documents that open user connections will not be closed. By revoking all
// grants, at least we ensure that the open connection is useless. Dropping the // grants, at least we ensure that the open connection is useless. Dropping the
// user will only affect the next connection. // user will only affect the next connection.
const defaultRevokeSQL = ` const defaultRevocationSQL = `
REVOKE ALL PRIVILEGES, GRANT OPTION FROM '{{name}}'@'%'; REVOKE ALL PRIVILEGES, GRANT OPTION FROM '{{name}}'@'%';
DROP USER '{{name}}'@'%' DROP USER '{{name}}'@'%'
` `
@@ -34,11 +34,6 @@ func secretCreds(b *backend) *framework.Secret {
Type: framework.TypeString, Type: framework.TypeString,
Description: "Password", Description: "Password",
}, },
"role": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Role",
},
}, },
Renew: b.secretCredsRenew, Renew: b.secretCredsRenew,
@@ -93,10 +88,10 @@ func (b *backend) secretCredsRevoke(
} }
// Use a default SQL statement for revocation if one cannot be fetched from the role // Use a default SQL statement for revocation if one cannot be fetched from the role
revokeSQL := defaultRevokeSQL revocationSQL := defaultRevocationSQL
if role != nil && role.RevokeSQL != "" { if role != nil && role.RevocationSQL != "" {
revokeSQL = role.RevokeSQL revocationSQL = role.RevocationSQL
} else { } else {
if resp == nil { if resp == nil {
resp = &logical.Response{} resp = &logical.Response{}
@@ -111,7 +106,7 @@ func (b *backend) secretCredsRevoke(
} }
defer tx.Rollback() defer tx.Rollback()
for _, query := range strutil.ParseArbitraryStringSlice(revokeSQL, ";") { for _, query := range strutil.ParseArbitraryStringSlice(revocationSQL, ";") {
query = strings.TrimSpace(query) query = strings.TrimSpace(query)
if len(query) == 0 { if len(query) == 0 {
continue continue

View File

@@ -233,6 +233,39 @@ func TestBackend_roleReadOnly(t *testing.T) {
}) })
} }
func TestBackend_roleReadOnly_revocationSQL(t *testing.T) {
config := logical.TestBackendConfig()
config.StorageView = &logical.InmemStorage{}
b, err := Factory(config)
if err != nil {
t.Fatal(err)
}
cid, connURL := prepareTestContainer(t, config.StorageView, b)
if cid != "" {
defer cleanupTestContainer(t, cid)
}
connData := map[string]interface{}{
"connection_url": connURL,
}
logicaltest.Test(t, logicaltest.TestCase{
Backend: b,
Steps: []logicaltest.TestStep{
testAccStepConfig(t, connData, false),
testAccStepCreateRoleWithRevocationSQL(t, "web", testRole, defaultRevocationSQL, false),
testAccStepCreateRoleWithRevocationSQL(t, "web-readonly", testReadOnlyRole, defaultRevocationSQL, false),
testAccStepReadRole(t, "web-readonly", testReadOnlyRole),
testAccStepCreateTable(t, b, config.StorageView, "web", connURL),
testAccStepReadCreds(t, b, config.StorageView, "web-readonly", connURL),
testAccStepDropTable(t, b, config.StorageView, "web", connURL),
testAccStepDeleteRole(t, "web-readonly"),
testAccStepDeleteRole(t, "web"),
testAccStepReadRole(t, "web-readonly", ""),
},
})
}
func testAccStepConfig(t *testing.T, d map[string]interface{}, expectError bool) logicaltest.TestStep { func testAccStepConfig(t *testing.T, d map[string]interface{}, expectError bool) logicaltest.TestStep {
return logicaltest.TestStep{ return logicaltest.TestStep{
Operation: logical.UpdateOperation, Operation: logical.UpdateOperation,
@@ -273,6 +306,18 @@ func testAccStepCreateRole(t *testing.T, name string, sql string, expectFail boo
} }
} }
func testAccStepCreateRoleWithRevocationSQL(t *testing.T, name, sql, revocationSQL string, expectFail bool) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.UpdateOperation,
Path: path.Join("roles", name),
Data: map[string]interface{}{
"sql": sql,
"revocation_sql": revocationSQL,
},
ErrorOk: expectFail,
}
}
func testAccStepDeleteRole(t *testing.T, name string) logicaltest.TestStep { func testAccStepDeleteRole(t *testing.T, name string) logicaltest.TestStep {
return logicaltest.TestStep{ return logicaltest.TestStep{
Operation: logical.DeleteOperation, Operation: logical.DeleteOperation,
@@ -341,6 +386,7 @@ func testAccStepReadCreds(t *testing.T, b logical.Backend, s logical.Storage, na
InternalData: map[string]interface{}{ InternalData: map[string]interface{}{
"secret_type": "creds", "secret_type": "creds",
"username": d.Username, "username": d.Username,
"role": name,
}, },
}, },
}) })
@@ -543,7 +589,8 @@ GRANT CONNECT ON DATABASE "postgres" TO "{{name}}";
` `
var testBlockStatementRoleSlice = []string{ var testBlockStatementRoleSlice = []string{
`DO $$ `
DO $$
BEGIN BEGIN
IF NOT EXISTS (SELECT * FROM pg_catalog.pg_roles WHERE rolname='foo-role') THEN IF NOT EXISTS (SELECT * FROM pg_catalog.pg_roles WHERE rolname='foo-role') THEN
CREATE ROLE "foo-role"; CREATE ROLE "foo-role";
@@ -556,9 +603,18 @@ BEGIN
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA foo TO "foo-role"; GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA foo TO "foo-role";
END IF; END IF;
END END
$$`, $$
`,
`CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';`, `CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';`,
`GRANT "foo-role" TO "{{name}}";`, `GRANT "foo-role" TO "{{name}}";`,
`ALTER ROLE "{{name}}" SET search_path = foo;`, `ALTER ROLE "{{name}}" SET search_path = foo;`,
`GRANT CONNECT ON DATABASE "postgres" TO "{{name}}";`, `GRANT CONNECT ON DATABASE "postgres" TO "{{name}}";`,
} }
const defaultRevocationSQL = `
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM {{name}};
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM {{name}};
REVOKE USAGE ON SCHEMA public FROM {{name}};
DROP ROLE IF EXISTS {{name}};
`

View File

@@ -140,6 +140,7 @@ func (b *backend) pathRoleCreateRead(
"password": password, "password": password,
}, map[string]interface{}{ }, map[string]interface{}{
"username": username, "username": username,
"role": name,
}) })
resp.Secret.TTL = lease.Lease resp.Secret.TTL = lease.Lease
return resp, nil return resp, nil

View File

@@ -26,15 +26,20 @@ func pathRoles(b *backend) *framework.Path {
return &framework.Path{ return &framework.Path{
Pattern: "roles/" + framework.GenericNameRegex("name"), Pattern: "roles/" + framework.GenericNameRegex("name"),
Fields: map[string]*framework.FieldSchema{ Fields: map[string]*framework.FieldSchema{
"name": &framework.FieldSchema{ "name": {
Type: framework.TypeString, Type: framework.TypeString,
Description: "Name of the role.", Description: "Name of the role.",
}, },
"sql": &framework.FieldSchema{ "sql": {
Type: framework.TypeString, Type: framework.TypeString,
Description: "SQL string to create a user. See help for more info.", Description: "SQL string to create a user. See help for more info.",
}, },
"revocation_sql": {
Type: framework.TypeString,
Description: "SQL string to revoke a user. This is in beta; use with caution.",
},
}, },
Callbacks: map[logical.Operation]framework.OperationFunc{ Callbacks: map[logical.Operation]framework.OperationFunc{
@@ -85,11 +90,19 @@ func (b *backend) pathRoleRead(
return nil, nil return nil, nil
} }
return &logical.Response{ resp := &logical.Response{
Data: map[string]interface{}{ Data: map[string]interface{}{
"sql": role.SQL, "sql": role.SQL,
}, },
}, nil }
// TODO: This is separate because this is in beta in 0.6.2, so we don't
// want it to show up in the general case.
if role.RevocationSQL != "" {
resp.Data["revocation_sql"] = role.RevocationSQL
}
return resp, nil
} }
func (b *backend) pathRoleList( func (b *backend) pathRoleList(
@@ -135,6 +148,7 @@ func (b *backend) pathRoleCreate(
// Store it // Store it
entry, err := logical.StorageEntryJSON("role/"+name, &roleEntry{ entry, err := logical.StorageEntryJSON("role/"+name, &roleEntry{
SQL: sql, SQL: sql,
RevocationSQL: data.Get("revocation_sql").(string),
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -147,7 +161,8 @@ func (b *backend) pathRoleCreate(
} }
type roleEntry struct { type roleEntry struct {
SQL string `json:"sql"` SQL string `json:"sql" mapstructure:"sql" structs:"sql"`
RevocationSQL string `json:"revocation_sql" mapstructure:"revocation_sql" structs:"revocation_sql"`
} }
const pathRoleHelpSyn = ` const pathRoleHelpSyn = `

View File

@@ -3,7 +3,9 @@ package postgresql
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"strings"
"github.com/hashicorp/vault/helper/strutil"
"github.com/hashicorp/vault/logical" "github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework" "github.com/hashicorp/vault/logical/framework"
"github.com/lib/pq" "github.com/lib/pq"
@@ -91,12 +93,36 @@ func (b *backend) secretCredsRevoke(
} }
username, ok := usernameRaw.(string) username, ok := usernameRaw.(string)
var revocationSQL string
var resp *logical.Response
roleNameRaw, ok := req.Secret.InternalData["role"]
if ok {
role, err := b.Role(req.Storage, roleNameRaw.(string))
if err != nil {
return nil, err
}
if role == nil {
if resp == nil {
resp = &logical.Response{}
}
resp.AddWarning(fmt.Sprintf("Role %q cannot be found. Using default revocation SQL.", roleNameRaw.(string)))
} else {
revocationSQL = role.RevocationSQL
}
}
// Get our connection // Get our connection
db, err := b.DB(req.Storage) db, err := b.DB(req.Storage)
if err != nil { if err != nil {
return nil, err return nil, err
} }
switch revocationSQL {
// This is the default revocation logic. If revocation SQL is provided it
// is simply executed as-is.
case "":
// Check if the role exists // Check if the role exists
var exists bool var exists bool
err = db.QueryRow("SELECT exists (SELECT rolname FROM pg_roles WHERE rolname=$1);", username).Scan(&exists) err = db.QueryRow("SELECT exists (SELECT rolname FROM pg_roles WHERE rolname=$1);", username).Scan(&exists)
@@ -105,7 +131,7 @@ func (b *backend) secretCredsRevoke(
} }
if exists == false { if exists == false {
return nil, nil return resp, nil
} }
// Query for permissions; we need to revoke permissions before we can drop // Query for permissions; we need to revoke permissions before we can drop
@@ -206,5 +232,39 @@ func (b *backend) secretCredsRevoke(
return nil, err return nil, err
} }
return nil, nil // We have revocation SQL, execute directly, within a transaction
default:
tx, err := db.Begin()
if err != nil {
return nil, err
}
defer func() {
tx.Rollback()
}()
for _, query := range strutil.ParseArbitraryStringSlice(revocationSQL, ";") {
query = strings.TrimSpace(query)
if len(query) == 0 {
continue
}
stmt, err := tx.Prepare(Query(query, map[string]string{
"name": pq.QuoteIdentifier(username),
}))
if err != nil {
return nil, err
}
defer stmt.Close()
if _, err := stmt.Exec(); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
}
return resp, nil
} }

View File

@@ -248,12 +248,20 @@ the default on versions prior to that.
<li> <li>
<span class="param">sql</span> <span class="param">sql</span>
<span class="param-flags">required</span> <span class="param-flags">required</span>
The SQL statements executed to create and configure the role. Must be The SQL statements executed to create and configure a user. Must be a
a semicolon-separated string, a base64-encoded semicolon-separated semicolon-separated string, a base64-encoded semicolon-separated
string, a serialized JSON string array, or a base64-encoded serialized string, a serialized JSON string array, or a base64-encoded serialized
JSON string array. The '{{name}}' and '{{password}}' values will be JSON string array. The '{{name}}' and '{{password}}' values will be
substituted. substituted.
</li> </li>
<li>
<span class="param">revocation_sql</span>
<span class="param-flags">optional</span>
The SQL statements executed to revoke a user. Must be a
semicolon-separated string, a base64-encoded semicolon-separated
string, a serialized JSON string array, or a base64-encoded serialized
JSON string array. The '{{name}}' value will be substituted.
</li>
<li> <li>
<span class="param">rolename_length</span> <span class="param">rolename_length</span>
<span class="param-flags">optional</span> <span class="param-flags">optional</span>