diff --git a/internal/iam/action.go b/internal/iam/action.go index 7bb06ca58d..d467ffc92d 100644 --- a/internal/iam/action.go +++ b/internal/iam/action.go @@ -17,6 +17,18 @@ const ( ActionConnect Action = 8 ) +var ActionMap = map[string]Action{ + "unknown": ActionUnknown, + "list": ActionList, + "create": ActionCreate, + "update": ActionUpdate, + "read": ActionRead, + "delete": ActionDelete, + "authen": ActionAuthen, + "*": ActionAll, + "connect": ActionConnect, +} + func (a Action) String() string { return [...]string{ "unknown", @@ -26,7 +38,7 @@ func (a Action) String() string { "read", "delete", "authen", - "all", + "*", "connect", }[a] } diff --git a/internal/perms/acl.go b/internal/perms/acl.go new file mode 100644 index 0000000000..cc8b5a8ee8 --- /dev/null +++ b/internal/perms/acl.go @@ -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=;actions= +* id=;actions= +* id=;type=;actions= + +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=;actions= + case grant.id == "" && + grant.typ == resource.Type: + results.Allowed = true + return + + // id=;actions= + case (grant.id == resource.Id || grant.id == "*") && + grant.typ == "": + results.Allowed = true + return + + // id=;type=;actions= + case grant.id == resource.Pin && + grant.typ == resource.Type: + results.Allowed = true + return + } + } + return +} diff --git a/internal/perms/acl_test.go b/internal/perms/acl_test.go new file mode 100644 index 0000000000..d2ffb26339 --- /dev/null +++ b/internal/perms/acl_test.go @@ -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) + } + }) + } +} diff --git a/internal/perms/doc.go b/internal/perms/doc.go new file mode 100644 index 0000000000..6042be4ef8 --- /dev/null +++ b/internal/perms/doc.go @@ -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=;actions= +* id=;actions= +* id=;type=;actions= + +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 diff --git a/internal/perms/grants.go b/internal/perms/grants.go new file mode 100644 index 0000000000..4c6a8129fa --- /dev/null +++ b/internal/perms/grants.go @@ -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 +} diff --git a/internal/perms/grants_test.go b/internal/perms/grants_test.go new file mode 100644 index 0000000000..b114773293 --- /dev/null +++ b/internal/perms/grants_test.go @@ -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) + } + }) + } +}