mirror of https://github.com/hashicorp/boundary
Add permission grant string parsing and validation and ACL implementation (#109)
This adds parsing and validation for grant strings along with the ability to create canonical JSON and text strings from a grant. It also adds an ACL implementation, allowing a collection of grant strings to then be checked against a resource/action tuple and indicate if it's allowed. Note that only specific sets of provided data in grant strings is allowed. This vastly simplifies logic. Test coverage is 100%. A future update will consolidate and migrate types between this and IAM but I didn't want to mix that in with this.pull/119/head
parent
10e08e90f0
commit
f41bf6e31e
@ -0,0 +1,103 @@
|
||||
package perms
|
||||
|
||||
/*
|
||||
A really useful page to be aware of when looking at ACLs is
|
||||
https://hashicorp.atlassian.net/wiki/spaces/ICU/pages/866976600/API+Actions+and+Permissions
|
||||
speaking of which: TODO: put that chart in public docs.
|
||||
|
||||
Anyways, from that page you can see that there are really only a few patterns of
|
||||
ACLs that are ever allowed:
|
||||
|
||||
* type=<resource.type>;actions=<action>
|
||||
* id=<resource.id>;actions=<action>
|
||||
* id=<pin>;type=<resource.type>;actions=<action>
|
||||
|
||||
and of course a matching scope.
|
||||
|
||||
This makes it actually quite simple to perform the ACL checking. Much of ACL
|
||||
construction is thus synthesizing something reasonable from a set of Grants.
|
||||
*/
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/watchtower/internal/iam"
|
||||
)
|
||||
|
||||
// ACL provides an entry point into the permissions engine for determining if an
|
||||
// action is allowed on a resource based on a principal's (user or group) grants.
|
||||
type ACL struct {
|
||||
scopeMap map[string][]Grant
|
||||
}
|
||||
|
||||
// ACLResults provides a type for the permission's engine results so that we can
|
||||
// pass more detailed information along in the future if we want. It was useful
|
||||
// in Vault, may be useful here.
|
||||
type ACLResults struct {
|
||||
Allowed bool
|
||||
|
||||
// This is included but unexported for testing/debugging
|
||||
scopeMap map[string][]Grant
|
||||
}
|
||||
|
||||
// Resource defines something within watchtower that requires authorization
|
||||
// capabilities. Resources must have a ScopeId.
|
||||
type Resource struct {
|
||||
// ScopeId is the scope that contains the Resource.
|
||||
ScopeId string
|
||||
|
||||
// Id is the public id of the resource.
|
||||
Id string
|
||||
|
||||
// Type of resource.
|
||||
Type string
|
||||
|
||||
// Pin if defined would constrain the resource within the collection of the
|
||||
// pin id.
|
||||
Pin string
|
||||
}
|
||||
|
||||
// NewACL creates an ACL from the grants provided.
|
||||
func NewACL(grants ...Grant) ACL {
|
||||
ret := ACL{
|
||||
scopeMap: make(map[string][]Grant, len(grants)),
|
||||
}
|
||||
|
||||
for _, grant := range grants {
|
||||
ret.scopeMap[grant.scope.Id] = append(ret.scopeMap[grant.scope.Id], grant)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// Allowed determines if the grants for an ACL allow an action for a resource.
|
||||
func (a ACL) Allowed(resource Resource, action iam.Action) (results ACLResults) {
|
||||
// First, get the grants within the specified scope
|
||||
grants := a.scopeMap[resource.ScopeId]
|
||||
results.scopeMap = a.scopeMap
|
||||
|
||||
// Now, go through and check the cases indicated above
|
||||
for _, grant := range grants {
|
||||
if !(grant.actions[action] || grant.actions[iam.ActionAll]) {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
// type=<resource.type>;actions=<action>
|
||||
case grant.id == "" &&
|
||||
grant.typ == resource.Type:
|
||||
results.Allowed = true
|
||||
return
|
||||
|
||||
// id=<resource.id>;actions=<action>
|
||||
case (grant.id == resource.Id || grant.id == "*") &&
|
||||
grant.typ == "":
|
||||
results.Allowed = true
|
||||
return
|
||||
|
||||
// id=<pin>;type=<resource.type>;actions=<action>
|
||||
case grant.id == resource.Pin &&
|
||||
grant.typ == resource.Type:
|
||||
results.Allowed = true
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -0,0 +1,244 @@
|
||||
package perms
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/watchtower/internal/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_ACLAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type scopeGrant struct {
|
||||
scope Scope
|
||||
grants []string
|
||||
}
|
||||
type actionAllowed struct {
|
||||
action iam.Action
|
||||
allowed bool
|
||||
}
|
||||
type input struct {
|
||||
name string
|
||||
scopeGrants []scopeGrant
|
||||
resource Resource
|
||||
actionsAllowed []actionAllowed
|
||||
userId string
|
||||
}
|
||||
|
||||
// A set of common grants to use in the following tests
|
||||
commonGrants := []scopeGrant{
|
||||
{
|
||||
scope: Scope{Type: iam.OrganizationScope, Id: "org_a"},
|
||||
grants: []string{
|
||||
"id=a_bar;actions=read,update",
|
||||
"id=a_foo;type=host-catalog;actions=update,delete",
|
||||
"type=host-set;actions=list,create",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: Scope{Type: iam.OrganizationScope, Id: "org_b"},
|
||||
grants: []string{
|
||||
"project=proj_x;type=host-set;actions=list,create",
|
||||
"type=host;actions=*",
|
||||
"id=*;actions=authen",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: Scope{Type: iam.OrganizationScope, Id: "org_c"},
|
||||
grants: []string{
|
||||
"id={{user.id }};actions=create,update",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// See acl.go for expected allowed formats. The goal here is to basically
|
||||
// test those, but also test a whole bunch of nonconforming values.
|
||||
tests := []input{
|
||||
{
|
||||
name: "no grants",
|
||||
resource: Resource{ScopeId: "foo", Id: "bar", Type: "typ"},
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionCreate},
|
||||
{action: iam.ActionRead},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no overlap",
|
||||
resource: Resource{ScopeId: "foo", Id: "bar", Type: "typ"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionCreate},
|
||||
{action: iam.ActionRead},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matching scope and id no matching action",
|
||||
resource: Resource{ScopeId: "org_a", Id: "a_foo"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionUpdate},
|
||||
{action: iam.ActionDelete},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matching scope and id and matching action",
|
||||
resource: Resource{ScopeId: "org_a", Id: "a_bar"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionRead, allowed: true},
|
||||
{action: iam.ActionUpdate, allowed: true},
|
||||
{action: iam.ActionDelete},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matching scope and id and all action",
|
||||
resource: Resource{ScopeId: "org_b", Type: "host"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionRead, allowed: true},
|
||||
{action: iam.ActionUpdate, allowed: true},
|
||||
{action: iam.ActionDelete, allowed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matching scope and id and all action but bad specifier",
|
||||
resource: Resource{ScopeId: "org_b", Id: "id_g"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionRead},
|
||||
{action: iam.ActionUpdate},
|
||||
{action: iam.ActionDelete},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matching scope and not matching type",
|
||||
resource: Resource{ScopeId: "org_a", Type: "host-catalog"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionUpdate},
|
||||
{action: iam.ActionDelete},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matching scope and matching type",
|
||||
resource: Resource{ScopeId: "org_a", Type: "host-set"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionList, allowed: true},
|
||||
{action: iam.ActionCreate, allowed: true},
|
||||
{action: iam.ActionDelete},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matching scope, type, action, bad pin",
|
||||
resource: Resource{ScopeId: "org_a", Id: "a_foo", Type: "host-catalog"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionUpdate},
|
||||
{action: iam.ActionDelete},
|
||||
{action: iam.ActionRead},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matching scope, type, action, random id and bad pin",
|
||||
resource: Resource{ScopeId: "org_a", Id: "anything", Type: "host-catalog", Pin: "a_bar"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionUpdate},
|
||||
{action: iam.ActionDelete},
|
||||
{action: iam.ActionRead},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matching scope, type, action, random id and good pin",
|
||||
resource: Resource{ScopeId: "org_a", Id: "anything", Type: "host-catalog", Pin: "a_foo"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionUpdate, allowed: true},
|
||||
{action: iam.ActionDelete, allowed: true},
|
||||
{action: iam.ActionRead},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong scope and matching type",
|
||||
resource: Resource{ScopeId: "org_bad", Type: "host-set"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionList},
|
||||
{action: iam.ActionCreate},
|
||||
{action: iam.ActionDelete},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cross project, bad project",
|
||||
resource: Resource{ScopeId: "proj_y", Type: "host-set"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionList},
|
||||
{action: iam.ActionCreate},
|
||||
{action: iam.ActionDelete},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cross project, good project",
|
||||
resource: Resource{ScopeId: "proj_x", Type: "host-set"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionList, allowed: true},
|
||||
{action: iam.ActionCreate, allowed: true},
|
||||
{action: iam.ActionDelete},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "any id",
|
||||
resource: Resource{ScopeId: "org_b", Type: "host-set"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionList},
|
||||
{action: iam.ActionAuthen, allowed: true},
|
||||
{action: iam.ActionDelete},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bad templated user id",
|
||||
resource: Resource{ScopeId: "org_c"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionList},
|
||||
{action: iam.ActionAuthen},
|
||||
{action: iam.ActionDelete},
|
||||
},
|
||||
userId: "u_abcd1234",
|
||||
},
|
||||
{
|
||||
name: "good templated user id",
|
||||
resource: Resource{ScopeId: "org_c", Id: "u_abcd1234"},
|
||||
scopeGrants: commonGrants,
|
||||
actionsAllowed: []actionAllowed{
|
||||
{action: iam.ActionCreate, allowed: true},
|
||||
{action: iam.ActionUpdate, allowed: true},
|
||||
},
|
||||
userId: "u_abcd1234",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var grants []Grant
|
||||
for _, sg := range test.scopeGrants {
|
||||
scope := sg.scope
|
||||
for _, g := range sg.grants {
|
||||
grant, err := Parse(scope, test.userId, g)
|
||||
require.NoError(t, err)
|
||||
grants = append(grants, grant)
|
||||
}
|
||||
}
|
||||
acl := NewACL(grants...)
|
||||
for _, aa := range test.actionsAllowed {
|
||||
assert.True(t, acl.Allowed(test.resource, aa.action).Allowed == aa.allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
/*
|
||||
Package perms provides the watchtower permissions engine using grants which are
|
||||
tied to IAM Roles within a Scope.
|
||||
|
||||
A really useful page to be aware of when looking at ACLs is
|
||||
https://hashicorp.atlassian.net/wiki/spaces/ICU/pages/866976600/API+Actions+and+Permissions
|
||||
speaking of which: TODO: put that chart in public docs.
|
||||
|
||||
Anyways, from that page you can see that there are really only a few patterns of
|
||||
ACLs that are ever allowed:
|
||||
|
||||
* type=<resource.type>;actions=<action>
|
||||
* id=<resource.id>;actions=<action>
|
||||
* id=<pin>;type=<resource.type>;actions=<action>
|
||||
|
||||
and of course a matching scope.
|
||||
|
||||
This makes it actually quite simple to perform the ACL checking. Much of ACL
|
||||
construction is thus synthesizing something reasonable from a set of Grants.
|
||||
*/
|
||||
package perms
|
||||
@ -0,0 +1,351 @@
|
||||
package perms
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/watchtower/internal/iam"
|
||||
)
|
||||
|
||||
const (
|
||||
TypeNone = ""
|
||||
TypeAll = "*"
|
||||
TypeRole = "role"
|
||||
TypeGroup = "group"
|
||||
TypeUser = "user"
|
||||
TypeAuthMethod = "auth-method"
|
||||
TypeHostCatalog = "host-catalog"
|
||||
TypeHostSet = "host-set"
|
||||
TypeHost = "host"
|
||||
TypeTarget = "target"
|
||||
)
|
||||
|
||||
// Scope provides an in-memory representation of iam.Scope without the
|
||||
// underlying storage references or capabilities.
|
||||
type Scope struct {
|
||||
// Id is the public id of the iam.Scope
|
||||
Id string
|
||||
|
||||
// Type is the scope's type (org or project)
|
||||
Type iam.ScopeType
|
||||
}
|
||||
|
||||
// Grant is a Go representation of a parsed grant
|
||||
type Grant struct {
|
||||
// The scope ID, which will be a project ID or an org ID
|
||||
scope Scope
|
||||
|
||||
// Project, if defined
|
||||
project string
|
||||
|
||||
// The ID in the grant, if provided.
|
||||
id string
|
||||
|
||||
// The type, if provided
|
||||
typ string
|
||||
|
||||
// The set of actions being granted
|
||||
actions map[iam.Action]bool
|
||||
|
||||
// This is used as a temporary staging area before validating permissions to
|
||||
// allow the same validation code across grant string formats
|
||||
actionsBeingParsed []string
|
||||
}
|
||||
|
||||
func (g Grant) clone() *Grant {
|
||||
ret := &Grant{
|
||||
scope: g.scope,
|
||||
project: g.project,
|
||||
id: g.id,
|
||||
typ: g.typ,
|
||||
}
|
||||
if g.actionsBeingParsed != nil {
|
||||
ret.actionsBeingParsed = append(ret.actionsBeingParsed, g.actionsBeingParsed...)
|
||||
}
|
||||
if g.actions != nil {
|
||||
ret.actions = make(map[iam.Action]bool, len(g.actions))
|
||||
for action := range g.actions {
|
||||
ret.actions[action] = true
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// CanonicalString returns the canonical representation of the grant
|
||||
func (g Grant) CanonicalString() string {
|
||||
var builder []string
|
||||
if g.project != "" {
|
||||
builder = append(builder, fmt.Sprintf("project=%s", g.project))
|
||||
}
|
||||
|
||||
if g.id != "" {
|
||||
builder = append(builder, fmt.Sprintf("id=%s", g.id))
|
||||
}
|
||||
|
||||
if g.typ != TypeNone {
|
||||
builder = append(builder, fmt.Sprintf("type=%s", g.typ))
|
||||
}
|
||||
|
||||
if len(g.actions) > 0 {
|
||||
actions := make([]string, 0, len(g.actions))
|
||||
for action := range g.actions {
|
||||
actions = append(actions, action.String())
|
||||
}
|
||||
sort.Strings(actions)
|
||||
builder = append(builder, fmt.Sprintf("actions=%s", strings.Join(actions, ",")))
|
||||
}
|
||||
|
||||
return strings.Join(builder, ";")
|
||||
}
|
||||
|
||||
// MarshalJSON provides a custom marshaller for grants
|
||||
func (g Grant) MarshalJSON() ([]byte, error) {
|
||||
res := make(map[string]interface{}, 4)
|
||||
if g.project != "" {
|
||||
res["project"] = g.project
|
||||
}
|
||||
if g.id != "" {
|
||||
res["id"] = g.id
|
||||
}
|
||||
if g.typ != "" {
|
||||
res["type"] = g.typ
|
||||
}
|
||||
if len(g.actions) > 0 {
|
||||
actions := make([]string, 0, len(g.actions))
|
||||
for action := range g.actions {
|
||||
actions = append(actions, action.String())
|
||||
}
|
||||
sort.Strings(actions)
|
||||
res["actions"] = actions
|
||||
}
|
||||
return json.Marshal(res)
|
||||
}
|
||||
|
||||
// This is purposefully unexported since the values being set here are not being
|
||||
// checked for validity. This should only be called by the main parsing function
|
||||
// when JSON is detected.
|
||||
func (g *Grant) unmarshalJSON(data []byte) error {
|
||||
raw := make(map[string]interface{}, 4)
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if rawProj, ok := raw["project"]; ok {
|
||||
project, ok := rawProj.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to interpret %q as string", "project")
|
||||
}
|
||||
g.project = strings.ToLower(project)
|
||||
}
|
||||
if rawId, ok := raw["id"]; ok {
|
||||
id, ok := rawId.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to interpret %q as string", "id")
|
||||
}
|
||||
g.id = strings.ToLower(id)
|
||||
}
|
||||
if rawType, ok := raw["type"]; ok {
|
||||
typ, ok := rawType.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to interpret %q as string", "type")
|
||||
}
|
||||
g.typ = strings.ToLower(typ)
|
||||
}
|
||||
if rawActions, ok := raw["actions"]; ok {
|
||||
interfaceActions, ok := rawActions.([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to interpret %q as array", "actions")
|
||||
}
|
||||
if len(interfaceActions) > 0 {
|
||||
g.actionsBeingParsed = make([]string, 0, len(interfaceActions))
|
||||
for _, v := range interfaceActions {
|
||||
actionStr, ok := v.(string)
|
||||
switch {
|
||||
case !ok:
|
||||
return fmt.Errorf("unable to interpret %v in actions array as string", v)
|
||||
case actionStr == "":
|
||||
return errors.New("empty action found")
|
||||
default:
|
||||
g.actionsBeingParsed = append(g.actionsBeingParsed, strings.ToLower(actionStr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Grant) unmarshalText(grantString string) error {
|
||||
segments := strings.Split(grantString, ";")
|
||||
for _, segment := range segments {
|
||||
kv := strings.Split(segment, "=")
|
||||
|
||||
// Ensure we don't accept "foo=bar=baz", "=foo", or "foo="
|
||||
switch {
|
||||
case len(kv) != 2:
|
||||
return fmt.Errorf("segment %q not formatted correctly, wrong number of equal signs", segment)
|
||||
case len(kv[0]) == 0:
|
||||
return fmt.Errorf("segment %q not formatted correctly, missing key", segment)
|
||||
case len(kv[1]) == 0:
|
||||
return fmt.Errorf("segment %q not formatted correctly, missing value", segment)
|
||||
}
|
||||
|
||||
switch kv[0] {
|
||||
case "project":
|
||||
g.project = strings.ToLower(kv[1])
|
||||
|
||||
case "id":
|
||||
g.id = strings.ToLower(kv[1])
|
||||
|
||||
case "type":
|
||||
g.typ = strings.ToLower(kv[1])
|
||||
|
||||
case "actions":
|
||||
actions := strings.Split(kv[1], ",")
|
||||
if len(actions) > 0 {
|
||||
g.actionsBeingParsed = make([]string, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
if action == "" {
|
||||
return errors.New("empty action found")
|
||||
}
|
||||
g.actionsBeingParsed = append(g.actionsBeingParsed, strings.ToLower(action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse parses a grant string. Note that this does not do checking
|
||||
// of the validity of IDs and such; that's left for other parts of the system.
|
||||
// We may not check at all (e.g. let it be an authz-time failure) or could check
|
||||
// after submission to catch errors.
|
||||
//
|
||||
// The scope must be the org and project where this grant originated, not the
|
||||
// request.
|
||||
//
|
||||
// WARNING: It is the responsibility of the caller to validate that a returned
|
||||
// Grant matches the incoming scope and if not that the relationship is valid.
|
||||
// Specifically, if a project is specified as part of a grant, the grant's
|
||||
// returned scope will be a project scope with the associated project ID. The
|
||||
// caller must validate that the project ID is valid and that its enclosing
|
||||
// organization is the original organization scope. Likely this can be done in a
|
||||
// centralized helper context; however it's not done here to avoid reaching into
|
||||
// the database from within this package.
|
||||
func Parse(scope Scope, userId, grantString string) (Grant, error) {
|
||||
if len(grantString) == 0 {
|
||||
return Grant{}, errors.New("grant string is empty")
|
||||
}
|
||||
|
||||
switch scope.Type {
|
||||
case iam.ProjectScope, iam.OrganizationScope:
|
||||
default:
|
||||
return Grant{}, errors.New("invalid scope type")
|
||||
}
|
||||
|
||||
if scope.Id == "" {
|
||||
return Grant{}, errors.New("no scope ID provided")
|
||||
}
|
||||
|
||||
grant := Grant{
|
||||
scope: scope,
|
||||
}
|
||||
|
||||
switch {
|
||||
case grantString[0] == '{':
|
||||
if err := grant.unmarshalJSON([]byte(grantString)); err != nil {
|
||||
return Grant{}, fmt.Errorf("unable to parse JSON grant string: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
if err := grant.unmarshalText(grantString); err != nil {
|
||||
return Grant{}, fmt.Errorf("unable to parse grant string: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for templated user ID, and subtitute in with the authenticated user
|
||||
// if so
|
||||
if grant.id != "" && userId != "" && strings.HasPrefix(grant.id, "{{") {
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(grant.id, "{{"), "}}")
|
||||
id = strings.ToLower(strings.TrimSpace(id))
|
||||
switch id {
|
||||
case "user.id":
|
||||
grant.id = userId
|
||||
default:
|
||||
return Grant{}, fmt.Errorf("unknown template %q in grant %q value", grant.id, "id")
|
||||
}
|
||||
}
|
||||
|
||||
if err := grant.validateAndModifyProject(); err != nil {
|
||||
return Grant{}, err
|
||||
}
|
||||
|
||||
if err := grant.validateType(); err != nil {
|
||||
return Grant{}, err
|
||||
}
|
||||
|
||||
if err := grant.parseAndValidateActions(); err != nil {
|
||||
return Grant{}, err
|
||||
}
|
||||
|
||||
return grant, nil
|
||||
}
|
||||
|
||||
func (g *Grant) validateAndModifyProject() error {
|
||||
if g.project == "" {
|
||||
return nil
|
||||
}
|
||||
if g.scope.Type != iam.OrganizationScope {
|
||||
return errors.New("cannot specify a project in the grant when the scope is not an organization")
|
||||
}
|
||||
g.scope.Type = iam.ProjectScope
|
||||
g.scope.Id = g.project
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g Grant) validateType() error {
|
||||
switch g.typ {
|
||||
case TypeNone,
|
||||
TypeAll,
|
||||
TypeRole,
|
||||
TypeGroup,
|
||||
TypeUser,
|
||||
TypeAuthMethod,
|
||||
TypeHostCatalog,
|
||||
TypeHostSet,
|
||||
TypeHost,
|
||||
TypeTarget:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown type specifier %q", g.typ)
|
||||
}
|
||||
|
||||
func (g *Grant) parseAndValidateActions() error {
|
||||
if len(g.actionsBeingParsed) == 0 {
|
||||
return errors.New("no actions specified")
|
||||
}
|
||||
|
||||
for _, action := range g.actionsBeingParsed {
|
||||
if action == "" {
|
||||
return errors.New("empty action found")
|
||||
}
|
||||
if g.actions == nil {
|
||||
g.actions = make(map[iam.Action]bool, len(g.actionsBeingParsed))
|
||||
}
|
||||
if a := iam.ActionMap[action]; a == iam.ActionUnknown {
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
} else {
|
||||
g.actions[a] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(g.actions) > 1 && g.actions[iam.ActionAll] {
|
||||
return fmt.Errorf("%q cannot be specified with other actions", iam.ActionAll.String())
|
||||
}
|
||||
|
||||
g.actionsBeingParsed = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,542 @@
|
||||
package perms
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/watchtower/internal/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_ActionParsingValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type input struct {
|
||||
name string
|
||||
input Grant
|
||||
errResult string
|
||||
result Grant
|
||||
}
|
||||
|
||||
tests := []input{
|
||||
{
|
||||
name: "no actions",
|
||||
errResult: "no actions specified",
|
||||
},
|
||||
{
|
||||
name: "empty action",
|
||||
input: Grant{
|
||||
actionsBeingParsed: []string{"create", "", "read"},
|
||||
},
|
||||
errResult: "empty action found",
|
||||
},
|
||||
{
|
||||
name: "unknown action",
|
||||
input: Grant{
|
||||
actionsBeingParsed: []string{"create", "foobar", "read"},
|
||||
},
|
||||
errResult: `unknown action "foobar"`,
|
||||
},
|
||||
{
|
||||
name: "all",
|
||||
input: Grant{
|
||||
actionsBeingParsed: []string{"*"},
|
||||
},
|
||||
result: Grant{
|
||||
actions: map[iam.Action]bool{
|
||||
iam.ActionAll: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all valid plus all",
|
||||
input: Grant{
|
||||
actionsBeingParsed: []string{"list", "create", "update", "*", "read", "delete", "authen", "connect"},
|
||||
},
|
||||
errResult: `"*" cannot be specified with other actions`,
|
||||
},
|
||||
{
|
||||
name: "all valid",
|
||||
input: Grant{
|
||||
actionsBeingParsed: []string{"list", "create", "update", "read", "delete", "authen", "connect"},
|
||||
},
|
||||
result: Grant{
|
||||
actions: map[iam.Action]bool{
|
||||
iam.ActionList: true,
|
||||
iam.ActionCreate: true,
|
||||
iam.ActionUpdate: true,
|
||||
iam.ActionRead: true,
|
||||
iam.ActionDelete: true,
|
||||
iam.ActionAuthen: true,
|
||||
iam.ActionConnect: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := test.input.parseAndValidateActions()
|
||||
if test.errResult == "" {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.result, test.input)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, test.errResult, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ValidateType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type input struct {
|
||||
name string
|
||||
input Grant
|
||||
errResult string
|
||||
}
|
||||
|
||||
tests := []input{
|
||||
{
|
||||
name: "no specifier",
|
||||
},
|
||||
{
|
||||
name: "unknown specifier",
|
||||
input: Grant{
|
||||
typ: "foobar",
|
||||
},
|
||||
errResult: `unknown type specifier "foobar"`,
|
||||
},
|
||||
{
|
||||
name: "valid specifier",
|
||||
input: Grant{
|
||||
typ: TypeHostCatalog,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := test.input.validateType()
|
||||
if test.errResult == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, test.errResult, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ValidateProject(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type input struct {
|
||||
name string
|
||||
input Grant
|
||||
output Grant
|
||||
errResult string
|
||||
}
|
||||
|
||||
tests := []input{
|
||||
{
|
||||
name: "no project",
|
||||
input: Grant{
|
||||
scope: Scope{
|
||||
Type: iam.OrganizationScope,
|
||||
},
|
||||
},
|
||||
output: Grant{
|
||||
scope: Scope{
|
||||
Type: iam.OrganizationScope,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project, organization scope",
|
||||
input: Grant{
|
||||
project: "foobar",
|
||||
scope: Scope{
|
||||
Type: iam.OrganizationScope,
|
||||
},
|
||||
},
|
||||
output: Grant{
|
||||
project: "foobar",
|
||||
scope: Scope{
|
||||
Type: iam.ProjectScope,
|
||||
Id: "foobar",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project, non-organization scope",
|
||||
input: Grant{
|
||||
project: "foobar",
|
||||
scope: Scope{
|
||||
Type: iam.ProjectScope,
|
||||
},
|
||||
},
|
||||
errResult: "cannot specify a project in the grant when the scope is not an organization",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := test.input.validateAndModifyProject()
|
||||
if test.errResult == "" {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.output, test.input)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, test.errResult, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_MarshallingAndCloning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type input struct {
|
||||
name string
|
||||
input Grant
|
||||
jsonOutput string
|
||||
canonicalString string
|
||||
}
|
||||
|
||||
tests := []input{
|
||||
{
|
||||
name: "empty",
|
||||
input: Grant{
|
||||
scope: Scope{
|
||||
Type: iam.OrganizationScope,
|
||||
},
|
||||
},
|
||||
jsonOutput: `{}`,
|
||||
canonicalString: ``,
|
||||
},
|
||||
{
|
||||
name: "project",
|
||||
input: Grant{
|
||||
project: "foobar",
|
||||
scope: Scope{
|
||||
Type: iam.OrganizationScope,
|
||||
},
|
||||
},
|
||||
jsonOutput: `{"project":"foobar"}`,
|
||||
canonicalString: `project=foobar`,
|
||||
},
|
||||
{
|
||||
name: "project and type",
|
||||
input: Grant{
|
||||
project: "foobar",
|
||||
scope: Scope{
|
||||
Type: iam.ProjectScope,
|
||||
},
|
||||
typ: TypeGroup,
|
||||
},
|
||||
jsonOutput: `{"project":"foobar","type":"group"}`,
|
||||
canonicalString: `project=foobar;type=group`,
|
||||
},
|
||||
{
|
||||
name: "project, type, and id",
|
||||
input: Grant{
|
||||
id: "baz",
|
||||
project: "foobar",
|
||||
scope: Scope{
|
||||
Type: iam.ProjectScope,
|
||||
},
|
||||
typ: TypeGroup,
|
||||
},
|
||||
jsonOutput: `{"id":"baz","project":"foobar","type":"group"}`,
|
||||
canonicalString: `project=foobar;id=baz;type=group`,
|
||||
},
|
||||
{
|
||||
name: "everything",
|
||||
input: Grant{
|
||||
id: "baz",
|
||||
project: "foobar",
|
||||
scope: Scope{
|
||||
Type: iam.ProjectScope,
|
||||
},
|
||||
typ: TypeGroup,
|
||||
actions: map[iam.Action]bool{
|
||||
iam.ActionCreate: true,
|
||||
iam.ActionRead: true,
|
||||
},
|
||||
actionsBeingParsed: []string{"create", "read"},
|
||||
},
|
||||
jsonOutput: `{"actions":["create","read"],"id":"baz","project":"foobar","type":"group"}`,
|
||||
canonicalString: `project=foobar;id=baz;type=group;actions=create,read`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
output, err := test.input.MarshalJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.jsonOutput, string(output))
|
||||
assert.Equal(t, test.canonicalString, test.input.CanonicalString())
|
||||
assert.Equal(t, &test.input, test.input.clone())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Unmarshaling(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type input struct {
|
||||
name string
|
||||
jsonInput string
|
||||
textInput string
|
||||
jsonErr string
|
||||
textErr string
|
||||
expected Grant
|
||||
}
|
||||
|
||||
tests := []input{
|
||||
{
|
||||
name: "empty",
|
||||
expected: Grant{},
|
||||
jsonInput: `{}`,
|
||||
textInput: ``,
|
||||
textErr: `segment "" not formatted correctly, wrong number of equal signs`,
|
||||
},
|
||||
{
|
||||
name: "bad json",
|
||||
jsonInput: `w329uf`,
|
||||
jsonErr: "invalid character 'w' looking for beginning of value",
|
||||
},
|
||||
{
|
||||
name: "good project",
|
||||
expected: Grant{
|
||||
project: "foobar",
|
||||
},
|
||||
jsonInput: `{"project":"foobar"}`,
|
||||
textInput: `project=foobar`,
|
||||
},
|
||||
{
|
||||
name: "bad project",
|
||||
jsonInput: `{"project":true}`,
|
||||
jsonErr: `unable to interpret "project" as string`,
|
||||
textInput: `project=`,
|
||||
textErr: `segment "project=" not formatted correctly, missing value`,
|
||||
},
|
||||
{
|
||||
name: "good id",
|
||||
expected: Grant{
|
||||
id: "foobar",
|
||||
},
|
||||
jsonInput: `{"id":"foobar"}`,
|
||||
textInput: `id=foobar`,
|
||||
},
|
||||
{
|
||||
name: "bad id",
|
||||
jsonInput: `{"id":true}`,
|
||||
jsonErr: `unable to interpret "id" as string`,
|
||||
textInput: `=id`,
|
||||
textErr: `segment "=id" not formatted correctly, missing key`,
|
||||
},
|
||||
{
|
||||
name: "good type",
|
||||
expected: Grant{
|
||||
typ: "host-catalog",
|
||||
},
|
||||
jsonInput: `{"type":"host-catalog"}`,
|
||||
textInput: `type=host-catalog`,
|
||||
},
|
||||
{
|
||||
name: "bad type",
|
||||
jsonInput: `{"type":true}`,
|
||||
jsonErr: `unable to interpret "type" as string`,
|
||||
textInput: `type=host-catalog=id`,
|
||||
textErr: `segment "type=host-catalog=id" not formatted correctly, wrong number of equal signs`,
|
||||
},
|
||||
{
|
||||
name: "good actions",
|
||||
expected: Grant{
|
||||
actionsBeingParsed: []string{"create", "read"},
|
||||
},
|
||||
jsonInput: `{"actions":["create","read"]}`,
|
||||
textInput: `actions=create,read`,
|
||||
},
|
||||
{
|
||||
name: "bad actions",
|
||||
jsonInput: `{"actions":true}`,
|
||||
jsonErr: `unable to interpret "actions" as array`,
|
||||
textInput: `type=host-catalog=id`,
|
||||
textErr: `segment "type=host-catalog=id" not formatted correctly, wrong number of equal signs`,
|
||||
},
|
||||
{
|
||||
name: "empty actions",
|
||||
jsonInput: `{"actions":[""]}`,
|
||||
jsonErr: `empty action found`,
|
||||
textInput: `actions=,`,
|
||||
textErr: `empty action found`,
|
||||
},
|
||||
{
|
||||
name: "bad json action",
|
||||
jsonInput: `{"actions":[1, true]}`,
|
||||
jsonErr: `unable to interpret 1 in actions array as string`,
|
||||
},
|
||||
}
|
||||
|
||||
assert := assert.New(t)
|
||||
require := require.New(t)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var g Grant
|
||||
if test.jsonInput != "" {
|
||||
err := g.unmarshalJSON([]byte(test.jsonInput))
|
||||
if test.jsonErr != "" {
|
||||
require.Error(err)
|
||||
assert.Equal(test.jsonErr, err.Error())
|
||||
} else {
|
||||
require.NoError(err)
|
||||
assert.Equal(test.expected, g)
|
||||
}
|
||||
}
|
||||
g = Grant{}
|
||||
if test.textInput != "" {
|
||||
err := g.unmarshalText(test.textInput)
|
||||
if test.textErr != "" {
|
||||
require.Error(err)
|
||||
assert.Equal(test.textErr, err.Error())
|
||||
} else {
|
||||
require.NoError(err)
|
||||
assert.Equal(test.expected, g)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Parse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type input struct {
|
||||
name string
|
||||
input string
|
||||
userId string
|
||||
err string
|
||||
expected Grant
|
||||
}
|
||||
|
||||
tests := []input{
|
||||
{
|
||||
name: "empty",
|
||||
err: `grant string is empty`,
|
||||
},
|
||||
{
|
||||
name: "bad json",
|
||||
input: "{2:193}",
|
||||
err: `unable to parse JSON grant string:`,
|
||||
},
|
||||
{
|
||||
name: "bad text",
|
||||
input: "id=foo=bar",
|
||||
err: `unable to parse grant string:`,
|
||||
},
|
||||
{
|
||||
name: "bad type",
|
||||
input: "id=foobar;type=barfoo;actions=create,read",
|
||||
err: `unknown type specifier "barfoo"`,
|
||||
},
|
||||
{
|
||||
name: "bad actions",
|
||||
input: "id=foobar;type=host-catalog;actions=createread",
|
||||
err: `unknown action "createread"`,
|
||||
},
|
||||
{
|
||||
name: "good json",
|
||||
input: `{"project":"proj","id":"foobar","type":"host-catalog","actions":["create","read"]}`,
|
||||
expected: Grant{
|
||||
scope: Scope{
|
||||
Id: "proj",
|
||||
Type: iam.ProjectScope,
|
||||
},
|
||||
project: "proj",
|
||||
id: "foobar",
|
||||
typ: "host-catalog",
|
||||
actions: map[iam.Action]bool{
|
||||
iam.ActionCreate: true,
|
||||
iam.ActionRead: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "good text",
|
||||
input: `project=proj;id=foobar;type=host-catalog;actions=create,read`,
|
||||
expected: Grant{
|
||||
scope: Scope{
|
||||
Id: "proj",
|
||||
Type: iam.ProjectScope,
|
||||
},
|
||||
project: "proj",
|
||||
id: "foobar",
|
||||
typ: "host-catalog",
|
||||
actions: map[iam.Action]bool{
|
||||
iam.ActionCreate: true,
|
||||
iam.ActionRead: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bad user id template",
|
||||
input: `id={{superman}};actions=create,read`,
|
||||
userId: "u_abcd1234",
|
||||
err: `unknown template "{{superman}}" in grant "id" value`,
|
||||
},
|
||||
{
|
||||
name: "good user id template",
|
||||
input: `id={{ user.id}};actions=create,read`,
|
||||
userId: "u_abcd1234",
|
||||
expected: Grant{
|
||||
scope: Scope{
|
||||
Id: "scope",
|
||||
Type: iam.OrganizationScope,
|
||||
},
|
||||
id: "u_abcd1234",
|
||||
actions: map[iam.Action]bool{
|
||||
iam.ActionCreate: true,
|
||||
iam.ActionRead: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert := assert.New(t)
|
||||
require := require.New(t)
|
||||
|
||||
_, err := Parse(Scope{}, "", "")
|
||||
require.Error(err)
|
||||
assert.Equal("grant string is empty", err.Error())
|
||||
|
||||
_, err = Parse(Scope{}, "", "{}")
|
||||
require.Error(err)
|
||||
assert.Equal("invalid scope type", err.Error())
|
||||
|
||||
_, err = Parse(Scope{Type: iam.OrganizationScope}, "", "{}")
|
||||
require.Error(err)
|
||||
assert.Equal("no scope ID provided", err.Error())
|
||||
|
||||
_, err = Parse(Scope{Id: "foobar", Type: iam.ProjectScope}, "", `project=foobar`)
|
||||
require.Error(err)
|
||||
assert.Equal("cannot specify a project in the grant when the scope is not an organization", err.Error())
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
grant, err := Parse(Scope{Id: "scope", Type: iam.OrganizationScope}, test.userId, test.input)
|
||||
if test.err != "" {
|
||||
require.Error(err)
|
||||
assert.True(strings.HasPrefix(err.Error(), test.err))
|
||||
} else {
|
||||
require.NoError(err)
|
||||
assert.Equal(test.expected, grant)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue