mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
* chore: create type for unique role names Using `string` was confusing when something should be combined with org context, and when not to. Naming this new name, "RoleIdentifier"
267 lines
8.1 KiB
Go
267 lines
8.1 KiB
Go
package rbac
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/open-policy-agent/opa/ast"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
)
|
|
|
|
// BenchmarkRBACValueAllocation benchmarks the cost of allocating a rego input
|
|
// value. By default, `ast.InterfaceToValue` is used to convert the input,
|
|
// which uses json marshaling under the hood.
|
|
//
|
|
// Currently ast.Object.insert() is the slowest part of the process and allocates
|
|
// the most amount of bytes. This general approach copies all of our struct
|
|
// data and uses a lot of extra memory for handling things like sort order.
|
|
// A possible large improvement would be to implement the ast.Value interface directly.
|
|
func BenchmarkRBACValueAllocation(b *testing.B) {
|
|
actor := Subject{
|
|
Roles: RoleIdentifiers{ScopedRoleOrgMember(uuid.New()), ScopedRoleOrgAdmin(uuid.New()), RoleMember()},
|
|
ID: uuid.NewString(),
|
|
Scope: ScopeAll,
|
|
Groups: []string{uuid.NewString(), uuid.NewString(), uuid.NewString()},
|
|
}
|
|
obj := ResourceTemplate.
|
|
WithID(uuid.New()).
|
|
InOrg(uuid.New()).
|
|
WithOwner(uuid.NewString()).
|
|
WithGroupACL(map[string][]policy.Action{
|
|
uuid.NewString(): {policy.ActionRead, policy.ActionCreate},
|
|
uuid.NewString(): {policy.ActionRead, policy.ActionCreate},
|
|
uuid.NewString(): {policy.ActionRead, policy.ActionCreate},
|
|
}).WithACLUserList(map[string][]policy.Action{
|
|
uuid.NewString(): {policy.ActionRead, policy.ActionCreate},
|
|
uuid.NewString(): {policy.ActionRead, policy.ActionCreate},
|
|
})
|
|
|
|
jsonSubject := authSubject{
|
|
ID: actor.ID,
|
|
Roles: must(actor.Roles.Expand()),
|
|
Groups: actor.Groups,
|
|
Scope: must(actor.Scope.Expand()),
|
|
}
|
|
|
|
b.Run("ManualRegoValue", func(b *testing.B) {
|
|
for i := 0; i < b.N; i++ {
|
|
_, err := regoInputValue(actor, policy.ActionRead, obj)
|
|
require.NoError(b, err)
|
|
}
|
|
})
|
|
b.Run("JSONRegoValue", func(b *testing.B) {
|
|
for i := 0; i < b.N; i++ {
|
|
_, err := ast.InterfaceToValue(map[string]interface{}{
|
|
"subject": jsonSubject,
|
|
"action": policy.ActionRead,
|
|
"object": obj,
|
|
})
|
|
require.NoError(b, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestRegoInputValue ensures the custom rego input parser returns the
|
|
// same value as the default json parser. The json parser is always correct,
|
|
// and the custom parser is used to reduce allocations. This optimization
|
|
// should yield the same results. Anything different is a bug.
|
|
func TestRegoInputValue(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Expand all roles and make sure we have a good copy.
|
|
// This is because these tests modify the roles, and we don't want to
|
|
// modify the original roles.
|
|
roles, err := RoleIdentifiers{ScopedRoleOrgMember(uuid.New()), ScopedRoleOrgAdmin(uuid.New()), RoleMember()}.Expand()
|
|
require.NoError(t, err, "failed to expand roles")
|
|
for i := range roles {
|
|
// If all cached values are nil, then the role will not use
|
|
// the shared cached value.
|
|
roles[i].cachedRegoValue = nil
|
|
}
|
|
|
|
actor := Subject{
|
|
Roles: Roles(roles),
|
|
ID: uuid.NewString(),
|
|
Scope: ScopeAll,
|
|
Groups: []string{uuid.NewString(), uuid.NewString(), uuid.NewString()},
|
|
}
|
|
|
|
obj := ResourceTemplate.
|
|
WithID(uuid.New()).
|
|
InOrg(uuid.New()).
|
|
WithOwner(uuid.NewString()).
|
|
WithGroupACL(map[string][]policy.Action{
|
|
uuid.NewString(): {policy.ActionRead, policy.ActionCreate},
|
|
uuid.NewString(): {policy.ActionRead, policy.ActionCreate},
|
|
uuid.NewString(): {policy.ActionRead, policy.ActionCreate},
|
|
}).WithACLUserList(map[string][]policy.Action{
|
|
uuid.NewString(): {policy.ActionRead, policy.ActionCreate},
|
|
uuid.NewString(): {policy.ActionRead, policy.ActionCreate},
|
|
})
|
|
|
|
action := policy.ActionRead
|
|
|
|
t.Run("InputValue", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// This is the input that would be passed to the rego policy.
|
|
jsonInput := map[string]interface{}{
|
|
"subject": authSubject{
|
|
ID: actor.ID,
|
|
Roles: must(actor.Roles.Expand()),
|
|
Groups: actor.Groups,
|
|
Scope: must(actor.Scope.Expand()),
|
|
},
|
|
"action": action,
|
|
"object": obj,
|
|
}
|
|
|
|
manual, err := regoInputValue(actor, action, obj)
|
|
require.NoError(t, err)
|
|
|
|
general, err := ast.InterfaceToValue(jsonInput)
|
|
require.NoError(t, err)
|
|
|
|
// The custom parser does not set these fields because they are not needed.
|
|
// To ensure the outputs are identical, intentionally overwrite all names
|
|
// to the same values.
|
|
ignoreNames(t, manual)
|
|
ignoreNames(t, general)
|
|
|
|
cmp := manual.Compare(general)
|
|
require.Equal(t, 0, cmp, "manual and general input values should be equal")
|
|
})
|
|
|
|
t.Run("PartialInputValue", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// This is the input that would be passed to the rego policy.
|
|
jsonInput := map[string]interface{}{
|
|
"subject": authSubject{
|
|
ID: actor.ID,
|
|
Roles: must(actor.Roles.Expand()),
|
|
Groups: actor.Groups,
|
|
Scope: must(actor.Scope.Expand()),
|
|
},
|
|
"action": action,
|
|
"object": map[string]interface{}{
|
|
"type": obj.Type,
|
|
},
|
|
}
|
|
|
|
manual, err := regoPartialInputValue(actor, action, obj.Type)
|
|
require.NoError(t, err)
|
|
|
|
general, err := ast.InterfaceToValue(jsonInput)
|
|
require.NoError(t, err)
|
|
|
|
// The custom parser does not set these fields because they are not needed.
|
|
// To ensure the outputs are identical, intentionally overwrite all names
|
|
// to the same values.
|
|
ignoreNames(t, manual)
|
|
ignoreNames(t, general)
|
|
|
|
cmp := manual.Compare(general)
|
|
require.Equal(t, 0, cmp, "manual and general input values should be equal")
|
|
})
|
|
}
|
|
|
|
// ignoreNames sets all names to "ignore" to ensure the values are identical.
|
|
func ignoreNames(t *testing.T, value ast.Value) {
|
|
t.Helper()
|
|
|
|
// Override the names of the roles
|
|
ref := ast.Ref{
|
|
ast.StringTerm("subject"),
|
|
ast.StringTerm("roles"),
|
|
}
|
|
roles, err := value.Find(ref)
|
|
require.NoError(t, err)
|
|
|
|
rolesArray, ok := roles.(*ast.Array)
|
|
require.True(t, ok, "roles is expected to be an array")
|
|
|
|
rolesArray.Foreach(func(term *ast.Term) {
|
|
obj, _ := term.Value.(ast.Object)
|
|
// Ignore all names
|
|
obj.Insert(ast.StringTerm("name"), ast.StringTerm("ignore"))
|
|
obj.Insert(ast.StringTerm("display_name"), ast.StringTerm("ignore"))
|
|
})
|
|
|
|
// Override the names of the scope role
|
|
ref = ast.Ref{
|
|
ast.StringTerm("subject"),
|
|
ast.StringTerm("scope"),
|
|
}
|
|
scope, err := value.Find(ref)
|
|
require.NoError(t, err)
|
|
|
|
scopeObj, ok := scope.(ast.Object)
|
|
require.True(t, ok, "scope is expected to be an object")
|
|
|
|
scopeObj.Insert(ast.StringTerm("name"), ast.StringTerm("ignore"))
|
|
scopeObj.Insert(ast.StringTerm("display_name"), ast.StringTerm("ignore"))
|
|
}
|
|
|
|
func TestRoleByName(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("BuiltIns", func(t *testing.T) {
|
|
t.Parallel()
|
|
testCases := []struct {
|
|
Role Role
|
|
}{
|
|
{Role: builtInRoles[owner](uuid.Nil)},
|
|
{Role: builtInRoles[member](uuid.Nil)},
|
|
{Role: builtInRoles[templateAdmin](uuid.Nil)},
|
|
{Role: builtInRoles[userAdmin](uuid.Nil)},
|
|
{Role: builtInRoles[auditor](uuid.Nil)},
|
|
|
|
{Role: builtInRoles[orgAdmin](uuid.New())},
|
|
{Role: builtInRoles[orgAdmin](uuid.New())},
|
|
{Role: builtInRoles[orgAdmin](uuid.New())},
|
|
|
|
{Role: builtInRoles[orgMember](uuid.New())},
|
|
{Role: builtInRoles[orgMember](uuid.New())},
|
|
{Role: builtInRoles[orgMember](uuid.New())},
|
|
}
|
|
|
|
for _, c := range testCases {
|
|
c := c
|
|
t.Run(c.Role.Identifier.String(), func(t *testing.T) {
|
|
role, err := RoleByName(c.Role.Identifier)
|
|
require.NoError(t, err, "role exists")
|
|
equalRoles(t, c.Role, role)
|
|
})
|
|
}
|
|
})
|
|
|
|
// nolint:paralleltest
|
|
t.Run("Errors", func(t *testing.T) {
|
|
var err error
|
|
|
|
_, err = RoleByName(RoleIdentifier{})
|
|
require.Error(t, err, "empty role")
|
|
|
|
_, err = RoleByName(RoleIdentifier{Name: orgMember})
|
|
require.Error(t, err, "expect orgID")
|
|
})
|
|
}
|
|
|
|
// SameAs compares 2 roles for equality.
|
|
func equalRoles(t *testing.T, a, b Role) {
|
|
require.Equal(t, a.Identifier, b.Identifier, "role names")
|
|
require.Equal(t, a.DisplayName, b.DisplayName, "role display names")
|
|
require.ElementsMatch(t, a.Site, b.Site, "site permissions")
|
|
require.ElementsMatch(t, a.User, b.User, "user permissions")
|
|
require.Equal(t, len(a.Org), len(b.Org), "same number of org roles")
|
|
|
|
for ak, av := range a.Org {
|
|
bv, ok := b.Org[ak]
|
|
require.True(t, ok, "org permissions missing: %s", ak)
|
|
require.ElementsMatchf(t, av, bv, "org %s permissions", ak)
|
|
}
|
|
}
|