Merge pull request #400 from hashicorp/f-glob

Change ACL semantics, use explicit glob and deny has highest precedence
This commit is contained in:
Armon Dadgar 2015-07-06 11:15:49 -06:00
commit 70cd3d1206
10 changed files with 314 additions and 97 deletions

View File

@ -5,23 +5,34 @@ import (
"github.com/hashicorp/vault/logical"
)
// operationPolicyLevel is used to map each logical operation
// into the minimum required permissions to allow the operation.
var operationPolicyLevel = map[logical.Operation]int{
logical.ReadOperation: pathPolicyLevel[PathPolicyRead],
logical.WriteOperation: pathPolicyLevel[PathPolicyWrite],
logical.DeleteOperation: pathPolicyLevel[PathPolicyWrite],
logical.ListOperation: pathPolicyLevel[PathPolicyRead],
logical.RevokeOperation: pathPolicyLevel[PathPolicyWrite],
logical.RenewOperation: pathPolicyLevel[PathPolicyRead],
logical.HelpOperation: pathPolicyLevel[PathPolicyDeny],
}
var (
// Policy lists are used to restrict what is eligible for an operation
anyPolicy = []string{PathPolicyDeny}
readWriteSudo = []string{PathPolicyRead, PathPolicyWrite, PathPolicySudo}
writeSudo = []string{PathPolicyWrite, PathPolicySudo}
// permittedPolicyLevel is used to map each logical operation
// into the set of policies that allow the operation.
permittedPolicyLevels = map[logical.Operation][]string{
logical.ReadOperation: readWriteSudo,
logical.WriteOperation: writeSudo,
logical.DeleteOperation: writeSudo,
logical.ListOperation: readWriteSudo,
logical.HelpOperation: anyPolicy,
logical.RevokeOperation: writeSudo,
logical.RenewOperation: writeSudo,
logical.RollbackOperation: writeSudo,
}
)
// ACL is used to wrap a set of policies to provide
// an efficient interface for access control.
type ACL struct {
// pathRules contains the path policies
pathRules *radix.Tree
// exactRules contains the path policies that are exact
exactRules *radix.Tree
// globRules contains the path policies that glob
globRules *radix.Tree
// root is enabled if the "root" named policy is present.
root bool
@ -31,8 +42,9 @@ type ACL struct {
func NewACL(policies []*Policy) (*ACL, error) {
// Initialize
a := &ACL{
pathRules: radix.New(),
root: false,
exactRules: radix.New(),
globRules: radix.New(),
root: false,
}
// Inject each policy
@ -46,21 +58,23 @@ func NewACL(policies []*Policy) (*ACL, error) {
a.root = true
}
for _, pp := range policy.Paths {
// Convert to a policy level
policyLevel := pathPolicyLevel[pp.Policy]
// Check which tree to use
tree := a.exactRules
if pp.Glob {
tree = a.globRules
}
// Check for an existing policy
raw, ok := a.pathRules.Get(pp.Prefix)
raw, ok := tree.Get(pp.Prefix)
if !ok {
a.pathRules.Insert(pp.Prefix, policyLevel)
tree.Insert(pp.Prefix, pp)
continue
}
existing := raw.(int)
existing := raw.(*PathPolicy)
// Check if this policy is a higher access level,
// we want to store the highest permission permitted.
if policyLevel > existing {
a.pathRules.Insert(pp.Prefix, policyLevel)
// Check if this policy is takes precedence
if pp.TakesPrecedence(existing) {
tree.Insert(pp.Prefix, pp)
}
}
}
@ -74,18 +88,36 @@ func (a *ACL) AllowOperation(op logical.Operation, path string) bool {
return true
}
// Find a matching rule, default deny if no match
policyLevel := 0
_, rule, ok := a.pathRules.LongestPrefix(path)
if ok {
policyLevel = rule.(int)
// Check if any policy level allows this operation
permitted := permittedPolicyLevels[op]
if permitted[0] == PathPolicyDeny {
return true
}
// Convert the operation to a minimum required level
requiredLevel := operationPolicyLevel[op]
// Find an exact matching rule, look for glob if no match
var policy *PathPolicy
raw, ok := a.exactRules.Get(path)
if ok {
policy = raw.(*PathPolicy)
goto CHECK
}
// Find a glob rule, default deny if no match
_, raw, ok = a.globRules.LongestPrefix(path)
if !ok {
return false
} else {
policy = raw.(*PathPolicy)
}
CHECK:
// Check if the minimum permissions are met
return policyLevel >= requiredLevel
for _, allowed := range permitted {
if allowed == policy.Policy {
return true
}
}
return false
}
// RootPrivilege checks if the user has root level permission
@ -97,13 +129,23 @@ func (a *ACL) RootPrivilege(path string) bool {
return true
}
// Check the rules for a match
_, rule, ok := a.pathRules.LongestPrefix(path)
if !ok {
return false
// Find an exact matching rule, look for glob if no match
var policy *PathPolicy
raw, ok := a.exactRules.Get(path)
if ok {
policy = raw.(*PathPolicy)
goto CHECK
}
// Check the rules for a match, default deny if no match
_, raw, ok = a.globRules.LongestPrefix(path)
if !ok {
return false
} else {
policy = raw.(*PathPolicy)
}
CHECK:
// Check the policy level
policyLevel := rule.(int)
return policyLevel == pathPolicyLevel[PathPolicySudo]
return policy.Policy == PathPolicySudo
}

View File

@ -105,13 +105,16 @@ func testLayeredACL(t *testing.T, acl *ACL) {
{logical.DeleteOperation, "stage/foo", true},
{logical.WriteOperation, "stage/aws/foo", false},
{logical.WriteOperation, "stage/aws/policy/foo", true},
{logical.WriteOperation, "stage/aws/policy/foo", false},
{logical.DeleteOperation, "prod/foo", true},
{logical.WriteOperation, "prod/foo", true},
{logical.ReadOperation, "prod/foo", true},
{logical.ListOperation, "prod/foo", true},
{logical.ReadOperation, "prod/aws/foo", false},
{logical.ReadOperation, "sys/status", false},
{logical.WriteOperation, "sys/seal", true},
}
for _, tc := range tcases {
@ -124,35 +127,41 @@ func testLayeredACL(t *testing.T, acl *ACL) {
var aclPolicy = `
name = "dev"
path "dev/" {
path "dev/*" {
policy = "sudo"
}
path "stage/" {
path "stage/*" {
policy = "write"
}
path "stage/aws/" {
path "stage/aws/*" {
policy = "read"
}
path "stage/aws/policy/" {
path "stage/aws/policy/*" {
policy = "sudo"
}
path "prod/" {
path "prod/*" {
policy = "read"
}
path "prod/aws/" {
path "prod/aws/*" {
policy = "deny"
}
path "sys/*" {
policy = "deny"
}
`
var aclPolicy2 = `
name = "ops"
path "dev/hide/" {
path "dev/hide/*" {
policy = "deny"
}
path "stage/aws/policy/" {
path "stage/aws/policy/*" {
policy = "deny"
}
path "prod/" {
path "prod/*" {
policy = "write"
}
path "sys/seal" {
policy = "write"
}
`

View File

@ -636,7 +636,7 @@ func TestCore_HandleRequest_PermissionAllowed(t *testing.T) {
Operation: logical.WriteOperation,
Path: "sys/policy/test",
Data: map[string]interface{}{
"rules": `path "secret/" { policy = "write" }`,
"rules": `path "secret/*" { policy = "write" }`,
},
ClientToken: root,
}

View File

@ -2,6 +2,7 @@ package vault
import (
"fmt"
"strings"
"github.com/hashicorp/hcl"
)
@ -13,15 +14,6 @@ const (
PathPolicySudo = "sudo"
)
var (
pathPolicyLevel = map[string]int{
PathPolicyDeny: 0,
PathPolicyRead: 1,
PathPolicyWrite: 2,
PathPolicySudo: 3,
}
)
// Policy is used to represent the policy specified by
// an ACL configuration.
type Policy struct {
@ -34,6 +26,47 @@ type Policy struct {
type PathPolicy struct {
Prefix string `hcl:",key"`
Policy string
Glob bool
}
// TakesPrecedence is used when multiple policies
// collide on a path to determine which policy takes
// precendence.
func (p *PathPolicy) TakesPrecedence(other *PathPolicy) bool {
// Handle the full merge matrix
switch p.Policy {
case PathPolicyDeny:
// Deny always takes precendence
return true
case PathPolicyRead:
// Read never takes precedence
return false
case PathPolicyWrite:
switch other.Policy {
case PathPolicyRead:
return true
case PathPolicyDeny, PathPolicyWrite, PathPolicySudo:
return false
default:
panic("missing case")
}
case PathPolicySudo:
switch other.Policy {
case PathPolicyRead, PathPolicyWrite:
return true
case PathPolicyDeny, PathPolicySudo:
return false
default:
panic("missing case")
}
default:
panic("missing case")
}
return false
}
// Parse is used to parse the specified ACL rules into an
@ -48,6 +81,13 @@ func Parse(rules string) (*Policy, error) {
// Validate the path policy
for _, pp := range p.Paths {
// Strip the glob character if found
if strings.HasSuffix(pp.Prefix, "*") {
pp.Prefix = strings.TrimSuffix(pp.Prefix, "*")
pp.Glob = true
}
// Check the policy is valid
switch pp.Policy {
case PathPolicyDeny:
case PathPolicyRead:

View File

@ -25,6 +25,12 @@ type PolicyStore struct {
lru *lru.Cache
}
// PolicyEntry is used to store a policy by name
type PolicyEntry struct {
Version int
Raw string
}
// NewPolicyStore creates a new PolicyStore that is backed
// using a given view. It used used to durable store and manage named policy.
func NewPolicyStore(view *BarrierView) *PolicyStore {
@ -64,9 +70,13 @@ func (ps *PolicyStore) SetPolicy(p *Policy) error {
return fmt.Errorf("policy name missing")
}
entry := &logical.StorageEntry{
Key: p.Name,
Value: []byte(p.Raw),
// Create the entry
entry, err := logical.StorageEntryJSON(p.Name, &PolicyEntry{
Version: 2,
Raw: p.Raw,
})
if err != nil {
return fmt.Errorf("failed to create entry: %v", err)
}
if err := ps.view.Put(entry); err != nil {
return fmt.Errorf("failed to persist policy: %v", err)
@ -101,16 +111,37 @@ func (ps *PolicyStore) GetPolicy(name string) (*Policy, error) {
return nil, nil
}
// Parse into a policy object
p, err := Parse(string(out.Value))
if err != nil {
return nil, fmt.Errorf("failed to parse policy: %v", err)
// In Vault 0.1.X we stored the raw policy, but in
// Vault 0.2 we switch to the PolicyEntry
policyEntry := new(PolicyEntry)
var policy *Policy
if err := out.DecodeJSON(policyEntry); err == nil {
// Parse normally
p, err := Parse(policyEntry.Raw)
if err != nil {
return nil, fmt.Errorf("failed to parse policy: %v", err)
}
p.Name = name
policy = p
} else {
// On error, attempt to use V1 parsing
p, err := Parse(string(out.Value))
if err != nil {
return nil, fmt.Errorf("failed to parse policy: %v", err)
}
p.Name = name
// V1 used implicit glob, we need to do a fix-up
for _, pp := range p.Paths {
pp.Glob = true
}
policy = p
}
p.Name = name
// Update the LRU cache
ps.lru.Add(p.Name, p)
return p, nil
ps.lru.Add(name, policy)
return policy, nil
}
// ListPolicies is used to list the available policies

View File

@ -3,6 +3,8 @@ package vault
import (
"reflect"
"testing"
"github.com/hashicorp/vault/logical"
)
func mockPolicyStore(t *testing.T) *PolicyStore {
@ -128,3 +130,26 @@ func TestPolicyStore_ACL(t *testing.T) {
}
testLayeredACL(t, acl)
}
func TestPolicyStore_v1Upgrade(t *testing.T) {
ps := mockPolicyStore(t)
// Put a V1 record
raw := `path "foo" { policy = "read" }`
ps.view.Put(&logical.StorageEntry{"old", []byte(raw)})
// Do a read
p, err := ps.GetPolicy("old")
if err != nil {
t.Fatalf("err: %v", err)
}
if p == nil || len(p.Paths) != 1 {
t.Fatalf("bad policy: %#v", p)
}
// Check that glob is enabled
if !p.Paths[0].Glob {
t.Fatalf("should enable glob")
}
}

View File

@ -5,6 +5,42 @@ import (
"testing"
)
func TestPolicy_TakesPrecedence(t *testing.T) {
type tcase struct {
a, b string
precedence bool
}
tests := []tcase{
tcase{PathPolicyDeny, PathPolicyDeny, true},
tcase{PathPolicyDeny, PathPolicyRead, true},
tcase{PathPolicyDeny, PathPolicyWrite, true},
tcase{PathPolicyDeny, PathPolicySudo, true},
tcase{PathPolicyRead, PathPolicyDeny, false},
tcase{PathPolicyRead, PathPolicyRead, false},
tcase{PathPolicyRead, PathPolicyWrite, false},
tcase{PathPolicyRead, PathPolicySudo, false},
tcase{PathPolicyWrite, PathPolicyDeny, false},
tcase{PathPolicyWrite, PathPolicyRead, true},
tcase{PathPolicyWrite, PathPolicyWrite, false},
tcase{PathPolicyWrite, PathPolicySudo, false},
tcase{PathPolicySudo, PathPolicyDeny, false},
tcase{PathPolicySudo, PathPolicyRead, true},
tcase{PathPolicySudo, PathPolicyWrite, true},
tcase{PathPolicySudo, PathPolicySudo, false},
}
for idx, test := range tests {
a := &PathPolicy{Policy: test.a}
b := &PathPolicy{Policy: test.b}
if out := a.TakesPrecedence(b); out != test.precedence {
t.Fatalf("bad: idx %d expect: %v out: %v",
idx, test.precedence, out)
}
}
}
func TestPolicy_Parse(t *testing.T) {
p, err := Parse(rawPolicy)
if err != nil {
@ -16,9 +52,9 @@ func TestPolicy_Parse(t *testing.T) {
}
expect := []*PathPolicy{
&PathPolicy{"", "deny"},
&PathPolicy{"stage/", "sudo"},
&PathPolicy{"prod/", "read"},
&PathPolicy{"", "deny", true},
&PathPolicy{"stage/", "sudo", true},
&PathPolicy{"prod/version", "read", false},
}
if !reflect.DeepEqual(p.Paths, expect) {
t.Fatalf("bad: %#v", p)
@ -30,17 +66,17 @@ var rawPolicy = `
name = "dev"
# Deny all paths by default
path "" {
path "*" {
policy = "deny"
}
# Allow full access to staging
path "stage/" {
path "stage/*" {
policy = "sudo"
}
# Limited read privilege to production
path "prod/" {
path "prod/version" {
policy = "read"
}
`

View File

@ -18,41 +18,47 @@ that describe what parts of Vault a user is allowed to access. An example
of a policy is shown below:
```javascript
path "sys" {
path "sys/*" {
policy = "deny"
}
path "secret" {
path "secret/*" {
policy = "write"
}
path "secret/foo" {
policy = "read"
}
path "secret/super-secret" {
policy = "deny"
}
```
Policies use prefix-based routing to apply rules. They are deny by default,
so if a path isn't explicitly given, Vault will reject any access to it.
Policies use path based matching to apply rules. A policy may be an exact
match, or might be a glob pattern which uses a prefix. The default policy
is always deny so if a path isn't explicitly allowed, Vault will reject access to it.
This works well due to Vault's architecture of being like a filesystem:
everything has a path associated with it, including the core configuration
mechanism under "sys".
~> Policy paths are matched using a longest-prefix match, which is the most
specific defined policy. This means if you define a policy for `"secret/foo"`,
the policy would also match `"secret/foobar"`.
~> Policy paths are matched using the most specific defined policy. This may
be an exact match or the longest-prefix match of a glob. This means if you
define a policy for `"secret/foo*"`, the policy would also match `"secret/foobar"`.
The glob character is only supported at the end of the path specification.
## Policies
Allowed policies for a path are:
* `deny` - No access allowed. Highest precedence.
* `sudo` - Read, write, and root access to a path.
* `write` - Read, write access to a path.
* `read` - Read-only access to a path.
* `deny` - No access allowed.
* `sudo` - Read, write, and root access to a path.
The only non-obvious policy is "sudo". Some routes within Vault and mounted
backends are marked as _root_ paths. Clients aren't allowed to access root
paths unless they are a root user (have the special policy "root") or
@ -95,3 +101,33 @@ If an _existing_ policy is modified, the modifications propagate
to all associated users instantly. The above paragraph is more specifically
stating that you can't add new or remove policies associated with an
active identity.
## Changes from 0.1
In Vault versions prior to 0.2, the ACL policy language had a slightly
different specification and semantics. The current specification requires
that glob behavior explicitly be specified by adding the `*` character to
the end of a path. Previously, all paths were glob based matches and no
exact match could be specified.
The other change is that deny had the lowest precedence. This meant if there
were two policies being merged (e.g. "ops" and "prod") and they had a conflicting
policy like:
```
path "sys/seal" {
policy = "deny"
}
path "sys/seal" {
policy = "read"
}
```
The merge would previously give the "read" higher precedence. The current
version of Vault prioritizes the explicit deny, so that the "deny" would
take precedence.
To make all Vault 0.1 policies compatible with Vault 0.2, the explicit
glob character must be added to all the path prefixes.

View File

@ -110,8 +110,8 @@ a level of access granted to a path in Vault. When the policies are merged (if m
policies are associated with a client), the highest access level permitted is used.
For example, if the "engineering" policy permits read/write access to the "eng/" path,
and the "ops" policy permits read access to the "ops/" path, then the user gets the
union of those. Policy is matched using a longest-prefix match, which is the most
specific defined policy.
union of those. Policy is matched using the most specific defined policy, which may be
an exact match or the longest-prefix match glob pattern.
Certain operations are only permitted by "root" users, which is a distinguished
policy built into Vault. This is similar to the concept of a root user on a Unix system

View File

@ -30,11 +30,7 @@ format that is also JSON-compatible, so you can use JSON as well. An example
policy is shown below:
```javascript
path "sys" {
policy = "deny"
}
path "secret" {
path "secret/*" {
policy = "write"
}
@ -43,14 +39,16 @@ path "secret/foo" {
}
```
The policy format uses a longest matching prefix system on the API path
to determine access control. Since everything in Vault must be accessed
via the API, this gives strict control over every aspect of Vault, including
mounting backends, authenticating, as well as secret access.
The policy format uses a prefix matching system on the API path
to determine access control. The most specific defined policy is used,
either an exact match or the longest-prefix glob match. Since everything
in Vault must be accessed via the API, this gives strict control over every
aspect of Vault, including mounting backends, authenticating, as well as secret access.
In the policy above, a user could write any secret to `secret/`, except
to `secret/foo`, where only read access is allowed. Policies default to
deny, so any access to an unspecified path is not allowed.
deny, so any access to an unspecified path is not allowed. The policy
langauge changed slightly in Vault 0.2, [see this page for details](/docs/concepts/policies.html).
Save the above policy as `acl.hcl`.