chore: Update rego to be partial execution friendly (#3449)

- Improves performance of batch authorization calls
- Enables possibility to convert rego auth calls into SQL WHERE clauses
This commit is contained in:
Steven Masley 2022-08-11 17:07:48 -05:00 committed by GitHub
parent 4a17e0d91f
commit 3ae42f4de9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1131 additions and 825 deletions

View File

@ -10,9 +10,21 @@ import (
"github.com/coder/coder/coderd/rbac"
)
func AuthorizeFilter[O rbac.Objecter](api *API, r *http.Request, action rbac.Action, objects []O) []O {
func AuthorizeFilter[O rbac.Objecter](api *API, r *http.Request, action rbac.Action, objects []O) ([]O, error) {
roles := httpmw.AuthorizationUserRoles(r)
return rbac.Filter(r.Context(), api.Authorizer, roles.ID.String(), roles.Roles, action, objects)
objects, err := rbac.Filter(r.Context(), api.Authorizer, roles.ID.String(), roles.Roles, action, objects)
if err != nil {
// Log the error as Filter should not be erroring.
api.Logger.Error(r.Context(), "filter failed",
slog.Error(err),
slog.F("user_id", roles.ID),
slog.F("username", roles.Username),
slog.F("route", r.URL.Path),
slog.F("action", action),
)
return nil, err
}
return objects, nil
}
// Authorize will return false if the user is not authorized to do the action.

View File

@ -63,7 +63,7 @@ func TestBuildInfo(t *testing.T) {
// TestAuthorizeAllEndpoints will check `authorize` is called on every endpoint registered.
func TestAuthorizeAllEndpoints(t *testing.T) {
t.Parallel()
authorizer := &fakeAuthorizer{}
authorizer := &recordingAuthorizer{}
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
@ -563,21 +563,41 @@ type authCall struct {
Object rbac.Object
}
type fakeAuthorizer struct {
type recordingAuthorizer struct {
Called *authCall
AlwaysReturn error
}
func (f *fakeAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error {
f.Called = &authCall{
func (r *recordingAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error {
r.Called = &authCall{
SubjectID: subjectID,
Roles: roleNames,
Action: action,
Object: object,
}
return f.AlwaysReturn
return r.AlwaysReturn
}
func (f *fakeAuthorizer) reset() {
f.Called = nil
func (r *recordingAuthorizer) PrepareByRoleName(_ context.Context, subjectID string, roles []string, action rbac.Action, _ string) (rbac.PreparedAuthorized, error) {
return &fakePreparedAuthorizer{
Original: r,
SubjectID: subjectID,
Roles: roles,
Action: action,
}, nil
}
func (r *recordingAuthorizer) reset() {
r.Called = nil
}
type fakePreparedAuthorizer struct {
Original *recordingAuthorizer
SubjectID string
Roles []string
Action rbac.Action
}
func (f *fakePreparedAuthorizer) Authorize(ctx context.Context, object rbac.Object) error {
return f.Original.ByRoleName(ctx, f.SubjectID, f.Roles, f.Action, object)
}

View File

@ -50,7 +50,14 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) {
if daemons == nil {
daemons = []database.ProvisionerDaemon{}
}
daemons = AuthorizeFilter(api, r, rbac.ActionRead, daemons)
daemons, err = AuthorizeFilter(api, r, rbac.ActionRead, daemons)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner daemons.",
Detail: err.Error(),
})
return
}
httpapi.Write(rw, http.StatusOK, daemons)
}

View File

@ -11,24 +11,41 @@ import (
type Authorizer interface {
ByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error
PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, objectType string) (PreparedAuthorized, error)
}
type PreparedAuthorized interface {
Authorize(ctx context.Context, object Object) error
}
// Filter takes in a list of objects, and will filter the list removing all
// the elements the subject does not have permission for.
// Filter does not allocate a new slice, and will use the existing one
// passed in. This can cause memory leaks if the slice is held for a prolonged
// period of time.
func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, action Action, objects []O) []O {
// the elements the subject does not have permission for. All objects must be
// of the same type.
func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, action Action, objects []O) ([]O, error) {
if len(objects) == 0 {
// Nothing to filter
return objects, nil
}
objectType := objects[0].RBACObject().Type
filtered := make([]O, 0)
prepared, err := auth.PrepareByRoleName(ctx, subjID, subjRoles, action, objectType)
if err != nil {
return nil, xerrors.Errorf("prepare: %w", err)
}
for i := range objects {
object := objects[i]
err := auth.ByRoleName(ctx, subjID, subjRoles, action, object.RBACObject())
rbacObj := object.RBACObject()
if rbacObj.Type != objectType {
return nil, xerrors.Errorf("object types must be uniform across the set (%s), found %s", objectType, object.RBACObject().Type)
}
err := prepared.Authorize(ctx, rbacObj)
if err == nil {
filtered = append(filtered, object)
}
}
return filtered
return filtered, nil
}
// RegoAuthorizer will use a prepared rego query for performing authorize()
@ -45,7 +62,7 @@ func NewAuthorizer() (*RegoAuthorizer, error) {
query, err := rego.New(
// allowed is the `allow` field from the prepared query. This is the field to check if authorization is
// granted.
rego.Query("allowed = data.authz.allow"),
rego.Query("data.authz.allow"),
rego.Module("policy.rego", policy),
).PrepareForEval(ctx)
@ -64,14 +81,11 @@ type authSubject struct {
// This is the function intended to be used outside this package.
// The role is fetched from the builtin map located in memory.
func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error {
roles := make([]Role, 0, len(roleNames))
for _, n := range roleNames {
r, err := RoleByName(n)
if err != nil {
return xerrors.Errorf("get role permissions: %w", err)
}
roles = append(roles, r)
roles, err := RolesByNames(roleNames)
if err != nil {
return err
}
return a.Authorize(ctx, subjectID, roles, action, object)
}
@ -92,18 +106,29 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles [
return ForbiddenWithInternal(xerrors.Errorf("eval rego: %w", err), input, results)
}
if len(results) != 1 {
return ForbiddenWithInternal(xerrors.Errorf("expect only 1 result, got %d", len(results)), input, results)
}
allowedResult, ok := (results[0].Bindings["allowed"]).(bool)
if !ok {
return ForbiddenWithInternal(xerrors.Errorf("expected allowed to be a bool but got %T", allowedResult), input, results)
}
if !allowedResult {
if !results.Allowed() {
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results)
}
return nil
}
// Prepare will partially execute the rego policy leaving the object fields unknown (except for the type).
// This will vastly speed up performance if batch authorization on the same type of objects is needed.
func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Role, action Action, objectType string) (*PartialAuthorizer, error) {
auth, err := newPartialAuthorizer(ctx, subjectID, roles, action, objectType)
if err != nil {
return nil, xerrors.Errorf("new partial authorizer: %w", err)
}
return auth, nil
}
func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, objectType string) (PreparedAuthorized, error) {
roles, err := RolesByNames(roleNames)
if err != nil {
return nil, err
}
return a.Prepare(ctx, subjectID, roles, action, objectType)
}

View File

@ -0,0 +1,708 @@
package rbac
import (
"context"
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/testutil"
)
// subject is required because rego needs
type subject struct {
UserID string `json:"id"`
// For the unit test we want to pass in the roles directly, instead of just
// by name. This allows us to test custom roles that do not exist in the product,
// but test edge cases of the implementation.
Roles []Role `json:"roles"`
}
type fakeObject struct {
Owner uuid.UUID
OrgOwner uuid.UUID
Type string
Allowed bool
}
func (w fakeObject) RBACObject() Object {
return Object{
Owner: w.Owner.String(),
OrgID: w.OrgOwner.String(),
Type: w.Type,
}
}
func TestFilterError(t *testing.T) {
t.Parallel()
auth, err := NewAuthorizer()
require.NoError(t, err)
_, err = Filter(context.Background(), auth, uuid.NewString(), []string{}, ActionRead, []Object{ResourceUser, ResourceWorkspace})
require.ErrorContains(t, err, "object types must be uniform")
}
// TestFilter ensures the filter acts the same as an individual authorize.
// It generates a random set of objects, then runs the Filter batch function
// against the singular ByRoleName function.
func TestFilter(t *testing.T) {
t.Parallel()
orgIDs := make([]uuid.UUID, 10)
userIDs := make([]uuid.UUID, len(orgIDs))
for i := range orgIDs {
orgIDs[i] = uuid.New()
userIDs[i] = uuid.New()
}
objects := make([]fakeObject, 0, len(userIDs)*len(orgIDs))
for i := range userIDs {
for j := range orgIDs {
objects = append(objects, fakeObject{
Owner: userIDs[i],
OrgOwner: orgIDs[j],
Type: ResourceWorkspace.Type,
Allowed: false,
})
}
}
testCases := []struct {
Name string
SubjectID string
Roles []string
Action Action
ObjectType string
}{
{
Name: "NoRoles",
SubjectID: userIDs[0].String(),
Roles: []string{},
ObjectType: ResourceWorkspace.Type,
Action: ActionRead,
},
{
Name: "Admin",
SubjectID: userIDs[0].String(),
Roles: []string{RoleOrgMember(orgIDs[0]), "auditor", RoleAdmin(), RoleMember()},
ObjectType: ResourceWorkspace.Type,
Action: ActionRead,
},
{
Name: "OrgAdmin",
SubjectID: userIDs[0].String(),
Roles: []string{RoleOrgMember(orgIDs[0]), RoleOrgAdmin(orgIDs[0]), RoleMember()},
ObjectType: ResourceWorkspace.Type,
Action: ActionRead,
},
{
Name: "OrgMember",
SubjectID: userIDs[0].String(),
Roles: []string{RoleOrgMember(orgIDs[0]), RoleOrgMember(orgIDs[1]), RoleMember()},
ObjectType: ResourceWorkspace.Type,
Action: ActionRead,
},
{
Name: "ManyRoles",
SubjectID: userIDs[0].String(),
Roles: []string{
RoleOrgMember(orgIDs[0]), RoleOrgAdmin(orgIDs[0]),
RoleOrgMember(orgIDs[1]), RoleOrgAdmin(orgIDs[1]),
RoleOrgMember(orgIDs[2]), RoleOrgAdmin(orgIDs[2]),
RoleOrgMember(orgIDs[4]),
RoleOrgMember(orgIDs[5]),
RoleMember(),
},
ObjectType: ResourceWorkspace.Type,
Action: ActionRead,
},
{
Name: "SiteMember",
SubjectID: userIDs[0].String(),
Roles: []string{RoleMember()},
ObjectType: ResourceUser.Type,
Action: ActionRead,
},
{
Name: "ReadOrgs",
SubjectID: userIDs[0].String(),
Roles: []string{
RoleOrgMember(orgIDs[0]),
RoleOrgMember(orgIDs[1]),
RoleOrgMember(orgIDs[2]),
RoleOrgMember(orgIDs[3]),
RoleMember(),
},
ObjectType: ResourceOrganization.Type,
Action: ActionRead,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
localObjects := make([]fakeObject, len(objects))
copy(localObjects, objects)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
auth, err := NewAuthorizer()
require.NoError(t, err, "new auth")
// Run auth 1 by 1
var allowedCount int
for i, obj := range localObjects {
obj.Type = tc.ObjectType
err := auth.ByRoleName(ctx, tc.SubjectID, tc.Roles, ActionRead, obj.RBACObject())
obj.Allowed = err == nil
if err == nil {
allowedCount++
}
localObjects[i] = obj
}
// Run by filter
list, err := Filter(ctx, auth, tc.SubjectID, tc.Roles, tc.Action, localObjects)
require.NoError(t, err)
require.Equal(t, allowedCount, len(list), "expected number of allowed")
for _, obj := range list {
require.True(t, obj.Allowed, "expected allowed")
}
})
}
}
// TestAuthorizeDomain test the very basic roles that are commonly used.
func TestAuthorizeDomain(t *testing.T) {
t.Parallel()
defOrg := uuid.New()
unuseID := uuid.New()
user := subject{
UserID: "me",
Roles: []Role{
must(RoleByName(RoleMember())),
must(RoleByName(RoleOrgMember(defOrg))),
},
}
testAuthorize(t, "Member", user, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: false},
{resource: ResourceWorkspace.WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: ResourceWorkspace.All(), actions: allActions(), allow: false},
// Other org + me
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
// Other org + other us
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
})
user = subject{
UserID: "me",
Roles: []Role{{
Name: "deny-all",
// List out deny permissions explicitly
Site: []Permission{
{
Negate: true,
ResourceType: WildcardSymbol,
Action: WildcardSymbol,
},
},
}},
}
testAuthorize(t, "DeletedMember", user, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: false},
{resource: ResourceWorkspace.WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.All(), actions: allActions(), allow: false},
// Other org + me
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
})
user = subject{
UserID: "me",
Roles: []Role{
must(RoleByName(RoleOrgAdmin(defOrg))),
must(RoleByName(RoleMember())),
},
}
testAuthorize(t, "OrgAdmin", user, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: true},
{resource: ResourceWorkspace.WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: ResourceWorkspace.All(), actions: allActions(), allow: false},
// Other org + me
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
})
user = subject{
UserID: "me",
Roles: []Role{
must(RoleByName(RoleAdmin())),
must(RoleByName(RoleMember())),
},
}
testAuthorize(t, "SiteAdmin", user, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: true},
{resource: ResourceWorkspace.WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: ResourceWorkspace.All(), actions: allActions(), allow: true},
// Other org + me
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: true},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: true},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: true},
{resource: ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: true},
})
// In practice this is a token scope on a regular subject.
// So this unit test does not represent a practical role. It is just
// testing the capabilities of the RBAC system.
user = subject{
UserID: "me",
Roles: []Role{
{
Name: "WorkspaceToken",
// This is at the site level to prevent the token from losing access if the user
// is kicked from the org
Site: []Permission{
{
Negate: false,
ResourceType: ResourceWorkspace.Type,
Action: ActionRead,
},
},
},
},
}
testAuthorize(t, "WorkspaceToken", user,
// Read Actions
cases(func(c authTestCase) authTestCase {
c.actions = []Action{ActionRead}
return c
}, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), allow: true},
{resource: ResourceWorkspace.InOrg(defOrg), allow: true},
{resource: ResourceWorkspace.WithOwner(user.UserID), allow: true},
{resource: ResourceWorkspace.All(), allow: true},
// Other org + me
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), allow: true},
{resource: ResourceWorkspace.InOrg(unuseID), allow: true},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: true},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), allow: true},
{resource: ResourceWorkspace.InOrg(unuseID), allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: true},
}),
// Not read actions
cases(func(c authTestCase) authTestCase {
c.actions = []Action{ActionCreate, ActionUpdate, ActionDelete}
c.allow = false
return c
}, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID)},
{resource: ResourceWorkspace.InOrg(defOrg)},
{resource: ResourceWorkspace.WithOwner(user.UserID)},
{resource: ResourceWorkspace.All()},
// Other org + me
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID)},
{resource: ResourceWorkspace.InOrg(unuseID)},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceWorkspace.WithOwner("not-me")},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unuseID)},
{resource: ResourceWorkspace.WithOwner("not-me")},
}),
)
// In practice this is a token scope on a regular subject
user = subject{
UserID: "me",
Roles: []Role{
{
Name: "ReadOnlyOrgAndUser",
Site: []Permission{},
Org: map[string][]Permission{
defOrg.String(): {{
Negate: false,
ResourceType: "*",
Action: ActionRead,
}},
},
User: []Permission{
{
Negate: false,
ResourceType: "*",
Action: ActionRead,
},
},
},
},
}
testAuthorize(t, "ReadOnly", user,
cases(func(c authTestCase) authTestCase {
c.actions = []Action{ActionRead}
return c
}, []authTestCase{
// Read
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), allow: true},
{resource: ResourceWorkspace.InOrg(defOrg), allow: true},
{resource: ResourceWorkspace.WithOwner(user.UserID), allow: true},
{resource: ResourceWorkspace.All(), allow: false},
// Other org + me
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), allow: false},
{resource: ResourceWorkspace.InOrg(unuseID), allow: false},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), allow: false},
{resource: ResourceWorkspace.InOrg(unuseID), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
}),
// Pass non-read actions
cases(func(c authTestCase) authTestCase {
c.actions = []Action{ActionCreate, ActionUpdate, ActionDelete}
c.allow = false
return c
}, []authTestCase{
// Read
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID)},
{resource: ResourceWorkspace.InOrg(defOrg)},
{resource: ResourceWorkspace.WithOwner(user.UserID)},
{resource: ResourceWorkspace.All()},
// Other org + me
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID)},
{resource: ResourceWorkspace.InOrg(unuseID)},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceWorkspace.WithOwner("not-me")},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unuseID)},
{resource: ResourceWorkspace.WithOwner("not-me")},
}))
}
// TestAuthorizeLevels ensures level overrides are acting appropriately
//nolint:paralleltest
func TestAuthorizeLevels(t *testing.T) {
defOrg := uuid.New()
unusedID := uuid.New()
user := subject{
UserID: "me",
Roles: []Role{
must(RoleByName(RoleAdmin())),
{
Name: "org-deny:" + defOrg.String(),
Org: map[string][]Permission{
defOrg.String(): {
{
Negate: true,
ResourceType: "*",
Action: "*",
},
},
},
},
{
Name: "user-deny-all",
// List out deny permissions explicitly
User: []Permission{
{
Negate: true,
ResourceType: WildcardSymbol,
Action: WildcardSymbol,
},
},
},
},
}
testAuthorize(t, "AdminAlwaysAllow", user,
cases(func(c authTestCase) authTestCase {
c.actions = allActions()
c.allow = true
return c
}, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID)},
{resource: ResourceWorkspace.InOrg(defOrg)},
{resource: ResourceWorkspace.WithOwner(user.UserID)},
{resource: ResourceWorkspace.All()},
// Other org + me
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.UserID)},
{resource: ResourceWorkspace.InOrg(unusedID)},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceWorkspace.WithOwner("not-me")},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.WithOwner("not-me")},
}))
user = subject{
UserID: "me",
Roles: []Role{
{
Name: "site-noise",
Site: []Permission{
{
Negate: true,
ResourceType: "random",
Action: WildcardSymbol,
},
},
},
must(RoleByName(RoleOrgAdmin(defOrg))),
{
Name: "user-deny-all",
// List out deny permissions explicitly
User: []Permission{
{
Negate: true,
ResourceType: WildcardSymbol,
Action: WildcardSymbol,
},
},
},
},
}
testAuthorize(t, "OrgAllowAll", user,
cases(func(c authTestCase) authTestCase {
c.actions = allActions()
return c
}, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), allow: true},
{resource: ResourceWorkspace.InOrg(defOrg), allow: true},
{resource: ResourceWorkspace.WithOwner(user.UserID), allow: false},
{resource: ResourceWorkspace.All(), allow: false},
// Other org + me
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.UserID), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), allow: false},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
}))
}
// cases applies a given function to all test cases. This makes generalities easier to create.
func cases(opt func(c authTestCase) authTestCase, cases []authTestCase) []authTestCase {
if opt == nil {
return cases
}
for i := range cases {
cases[i] = opt(cases[i])
}
return cases
}
type authTestCase struct {
resource Object
actions []Action
allow bool
}
func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTestCase) {
t.Helper()
authorizer, err := NewAuthorizer()
require.NoError(t, err)
for _, cases := range sets {
for _, c := range cases {
t.Run(name, func(t *testing.T) {
for _, a := range c.actions {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
t.Cleanup(cancel)
authError := authorizer.Authorize(ctx, subject.UserID, subject.Roles, a, c.resource)
// Logging only
if authError != nil {
var uerr *UnauthorizedError
xerrors.As(authError, &uerr)
d, _ := json.Marshal(uerr.Input())
t.Logf("input: %s", string(d))
t.Logf("internal error: %+v", uerr.Internal().Error())
t.Logf("output: %+v", uerr.Output())
} else {
d, _ := json.Marshal(map[string]interface{}{
"subject": subject,
"object": c.resource,
"action": a,
})
t.Log(string(d))
}
if c.allow {
assert.NoError(t, authError, "expected no error for testcase action %s", a)
} else {
assert.Error(t, authError, "expected unauthorized")
}
partialAuthz, err := authorizer.Prepare(ctx, subject.UserID, subject.Roles, a, c.resource.Type)
require.NoError(t, err, "make prepared authorizer")
// Also check the rego policy can form a valid partial query result.
// This ensures we can convert the queries into SQL WHERE clauses in the future.
// If this function returns 'Support' sections, then we cannot convert the query into SQL.
if len(partialAuthz.partialQueries.Support) > 0 {
d, _ := json.Marshal(partialAuthz.input)
t.Logf("input: %s", string(d))
for _, q := range partialAuthz.partialQueries.Queries {
t.Logf("query: %+v", q.String())
}
for _, s := range partialAuthz.partialQueries.Support {
t.Logf("support: %+v", s.String())
}
}
require.Equal(t, 0, len(partialAuthz.partialQueries.Support), "expected 0 support rules")
partialErr := partialAuthz.Authorize(ctx, c.resource)
if authError != nil {
assert.Error(t, partialErr, "partial error blocked valid request (false negative)")
} else {
assert.NoError(t, partialErr, "partial allowed invalid request (false positive)")
}
}
})
}
}
}
// allActions is a helper function to return all the possible actions types.
func allActions() []Action {
return []Action{ActionCreate, ActionRead, ActionUpdate, ActionDelete}
}
func must[T any](value T, err error) T {
if err != nil {
panic(err)
}
return value
}

View File

@ -1,605 +0,0 @@
package rbac_test
import (
"context"
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/rbac"
)
// subject is required because rego needs
type subject struct {
UserID string `json:"id"`
// For the unit test we want to pass in the roles directly, instead of just
// by name. This allows us to test custom roles that do not exist in the product,
// but test edge cases of the implementation.
Roles []rbac.Role `json:"roles"`
}
func TestFilter(t *testing.T) {
t.Parallel()
objectList := make([]rbac.Object, 0)
workspaceList := make([]rbac.Object, 0)
fileList := make([]rbac.Object, 0)
for i := 0; i < 10; i++ {
workspace := rbac.ResourceWorkspace.WithOwner("me")
file := rbac.ResourceFile.WithOwner("me")
workspaceList = append(workspaceList, workspace)
fileList = append(fileList, file)
objectList = append(objectList, workspace)
objectList = append(objectList, file)
}
// copyList is to prevent tests from sharing the same slice
copyList := func(list []rbac.Object) []rbac.Object {
tmp := make([]rbac.Object, len(list))
copy(tmp, list)
return tmp
}
testCases := []struct {
Name string
List []rbac.Object
Expected []rbac.Object
Auth func(o rbac.Object) error
}{
{
Name: "FilterWorkspaceType",
List: copyList(objectList),
Expected: copyList(workspaceList),
Auth: func(o rbac.Object) error {
if o.Type != rbac.ResourceWorkspace.Type {
return xerrors.New("only workspace")
}
return nil
},
},
{
Name: "FilterFileType",
List: copyList(objectList),
Expected: copyList(fileList),
Auth: func(o rbac.Object) error {
if o.Type != rbac.ResourceFile.Type {
return xerrors.New("only file")
}
return nil
},
},
{
Name: "FilterAll",
List: copyList(objectList),
Expected: []rbac.Object{},
Auth: func(o rbac.Object) error {
return xerrors.New("always fail")
},
},
{
Name: "FilterNone",
List: copyList(objectList),
Expected: copyList(objectList),
Auth: func(o rbac.Object) error {
return nil
},
},
}
for _, c := range testCases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
authorizer := fakeAuthorizer{
AuthFunc: func(_ context.Context, _ string, _ []string, _ rbac.Action, object rbac.Object) error {
return c.Auth(object)
},
}
filtered := rbac.Filter(context.Background(), authorizer, "me", []string{}, rbac.ActionRead, c.List)
require.ElementsMatch(t, c.Expected, filtered, "expect same list")
require.Equal(t, len(c.Expected), len(filtered), "same length list")
})
}
}
// TestAuthorizeDomain test the very basic roles that are commonly used.
func TestAuthorizeDomain(t *testing.T) {
t.Parallel()
defOrg := uuid.New()
unuseID := uuid.New()
user := subject{
UserID: "me",
Roles: []rbac.Role{
must(rbac.RoleByName(rbac.RoleMember())),
must(rbac.RoleByName(rbac.RoleOrgMember(defOrg))),
},
}
testAuthorize(t, "Member", user, []authTestCase{
// Org + me
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.All(), actions: allActions(), allow: false},
// Other org + me
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
// Other org + other user
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
// Other org + other us
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
})
user = subject{
UserID: "me",
Roles: []rbac.Role{{
Name: "deny-all",
// List out deny permissions explicitly
Site: []rbac.Permission{
{
Negate: true,
ResourceType: rbac.WildcardSymbol,
Action: rbac.WildcardSymbol,
},
},
}},
}
testAuthorize(t, "DeletedMember", user, []authTestCase{
// Org + me
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.All(), actions: allActions(), allow: false},
// Other org + me
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
// Other org + other user
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
// Other org + other use
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
})
user = subject{
UserID: "me",
Roles: []rbac.Role{
must(rbac.RoleByName(rbac.RoleOrgAdmin(defOrg))),
must(rbac.RoleByName(rbac.RoleMember())),
},
}
testAuthorize(t, "OrgAdmin", user, []authTestCase{
// Org + me
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.All(), actions: allActions(), allow: false},
// Other org + me
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
// Other org + other user
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
// Other org + other use
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
})
user = subject{
UserID: "me",
Roles: []rbac.Role{
must(rbac.RoleByName(rbac.RoleAdmin())),
must(rbac.RoleByName(rbac.RoleMember())),
},
}
testAuthorize(t, "SiteAdmin", user, []authTestCase{
// Org + me
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.All(), actions: allActions(), allow: true},
// Other org + me
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: true},
// Other org + other user
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: true},
// Other org + other use
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: true},
})
// In practice this is a token scope on a regular subject.
// So this unit test does not represent a practical role. It is just
// testing the capabilities of the RBAC system.
user = subject{
UserID: "me",
Roles: []rbac.Role{
{
Name: "WorkspaceToken",
// This is at the site level to prevent the token from losing access if the user
// is kicked from the org
Site: []rbac.Permission{
{
Negate: false,
ResourceType: rbac.ResourceWorkspace.Type,
Action: rbac.ActionRead,
},
},
},
},
}
testAuthorize(t, "WorkspaceToken", user,
// Read Actions
cases(func(c authTestCase) authTestCase {
c.actions = []rbac.Action{rbac.ActionRead}
return c
}, []authTestCase{
// Org + me
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID), allow: true},
{resource: rbac.ResourceWorkspace.All(), allow: true},
// Other org + me
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), allow: true},
// Other org + other user
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: true},
// Other org + other use
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: true},
}),
// Not read actions
cases(func(c authTestCase) authTestCase {
c.actions = []rbac.Action{rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete}
c.allow = false
return c
}, []authTestCase{
// Org + me
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg)},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.All()},
// Other org + me
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg(unuseID)},
// Other org + other user
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
// Other org + other use
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.InOrg(unuseID)},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
}),
)
// In practice this is a token scope on a regular subject
user = subject{
UserID: "me",
Roles: []rbac.Role{
{
Name: "ReadOnlyOrgAndUser",
Site: []rbac.Permission{},
Org: map[string][]rbac.Permission{
defOrg.String(): {{
Negate: false,
ResourceType: "*",
Action: rbac.ActionRead,
}},
},
User: []rbac.Permission{
{
Negate: false,
ResourceType: "*",
Action: rbac.ActionRead,
},
},
},
},
}
testAuthorize(t, "ReadOnly", user,
cases(func(c authTestCase) authTestCase {
c.actions = []rbac.Action{rbac.ActionRead}
return c
}, []authTestCase{
// Read
// Org + me
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID), allow: true},
{resource: rbac.ResourceWorkspace.All(), allow: false},
// Other org + me
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), allow: false},
// Other org + other user
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
// Other org + other use
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unuseID), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
}),
// Pass non-read actions
cases(func(c authTestCase) authTestCase {
c.actions = []rbac.Action{rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete}
c.allow = false
return c
}, []authTestCase{
// Read
// Org + me
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg)},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.All()},
// Other org + me
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg(unuseID)},
// Other org + other user
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
// Other org + other use
{resource: rbac.ResourceWorkspace.InOrg(unuseID).WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.InOrg(unuseID)},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
}))
}
// TestAuthorizeLevels ensures level overrides are acting appropriately
//nolint:paralleltest
func TestAuthorizeLevels(t *testing.T) {
defOrg := uuid.New()
unusedID := uuid.New()
user := subject{
UserID: "me",
Roles: []rbac.Role{
must(rbac.RoleByName(rbac.RoleAdmin())),
{
Name: "org-deny:" + defOrg.String(),
Org: map[string][]rbac.Permission{
defOrg.String(): {
{
Negate: true,
ResourceType: "*",
Action: "*",
},
},
},
},
{
Name: "user-deny-all",
// List out deny permissions explicitly
User: []rbac.Permission{
{
Negate: true,
ResourceType: rbac.WildcardSymbol,
Action: rbac.WildcardSymbol,
},
},
},
},
}
testAuthorize(t, "AdminAlwaysAllow", user,
cases(func(c authTestCase) authTestCase {
c.actions = allActions()
c.allow = true
return c
}, []authTestCase{
// Org + me
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg)},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.All()},
// Other org + me
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg(unusedID)},
// Other org + other user
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
// Other org + other use
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.InOrg(unusedID)},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
}))
user = subject{
UserID: "me",
Roles: []rbac.Role{
{
Name: "site-noise",
Site: []rbac.Permission{
{
Negate: true,
ResourceType: "random",
Action: rbac.WildcardSymbol,
},
},
},
must(rbac.RoleByName(rbac.RoleOrgAdmin(defOrg))),
{
Name: "user-deny-all",
// List out deny permissions explicitly
User: []rbac.Permission{
{
Negate: true,
ResourceType: rbac.WildcardSymbol,
Action: rbac.WildcardSymbol,
},
},
},
},
}
testAuthorize(t, "OrgAllowAll", user,
cases(func(c authTestCase) authTestCase {
c.actions = allActions()
return c
}, []authTestCase{
// Org + me
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.All(), allow: false},
// Other org + me
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unusedID), allow: false},
// Other org + other user
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
// Other org + other use
{resource: rbac.ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(unusedID), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
}))
}
// cases applies a given function to all test cases. This makes generalities easier to create.
func cases(opt func(c authTestCase) authTestCase, cases []authTestCase) []authTestCase {
if opt == nil {
return cases
}
for i := range cases {
cases[i] = opt(cases[i])
}
return cases
}
type authTestCase struct {
resource rbac.Object
actions []rbac.Action
allow bool
}
func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTestCase) {
t.Helper()
authorizer, err := rbac.NewAuthorizer()
require.NoError(t, err)
for _, cases := range sets {
for _, c := range cases {
t.Run(name, func(t *testing.T) {
for _, a := range c.actions {
err := authorizer.Authorize(context.Background(), subject.UserID, subject.Roles, a, c.resource)
if c.allow {
if err != nil {
var uerr *rbac.UnauthorizedError
xerrors.As(err, &uerr)
d, _ := json.Marshal(uerr.Input())
t.Logf("input: %s", string(d))
t.Logf("internal error: %+v", uerr.Internal().Error())
t.Logf("output: %+v", uerr.Output())
}
require.NoError(t, err, "expected no error for testcase action %s", a)
continue
}
if err == nil {
d, _ := json.Marshal(map[string]interface{}{
"subject": subject,
"object": c.resource,
"action": a,
})
t.Log(string(d))
}
require.Error(t, err, "expected unauthorized")
}
})
}
}
}
// allActions is a helper function to return all the possible actions types.
func allActions() []rbac.Action {
return []rbac.Action{rbac.ActionCreate, rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete}
}

View File

@ -222,6 +222,18 @@ func RoleByName(name string) (Role, error) {
return role, nil
}
func RolesByNames(roleNames []string) ([]Role, error) {
roles := make([]Role, 0, len(roleNames))
for _, n := range roleNames {
r, err := RoleByName(n)
if err != nil {
return nil, xerrors.Errorf("get role permissions: %w", err)
}
roles = append(roles, r)
}
return roles, nil
}
func IsOrgRole(roleName string) (string, bool) {
_, orgID, err := roleSplit(roleName)
if err == nil && orgID != "" {

View File

@ -62,7 +62,8 @@ func BenchmarkRBACFilter(b *testing.B) {
rbac.RoleOrgMember(orgs[0]), rbac.RoleOrgAdmin(orgs[0]),
rbac.RoleOrgMember(orgs[1]), rbac.RoleOrgAdmin(orgs[1]),
rbac.RoleOrgMember(orgs[2]), rbac.RoleOrgAdmin(orgs[2]),
rbac.RoleMember()},
rbac.RoleMember(),
},
UserID: users[0],
},
}
@ -75,7 +76,8 @@ func BenchmarkRBACFilter(b *testing.B) {
b.Run(c.Name, func(b *testing.B) {
objects := benchmarkSetup(orgs, users, b.N)
b.ResetTimer()
allowed := rbac.Filter(context.Background(), authorizer, c.UserID.String(), c.Roles, rbac.ActionRead, objects)
allowed, err := rbac.Filter(context.Background(), authorizer, c.UserID.String(), c.Roles, rbac.ActionRead, objects)
require.NoError(b, err)
var _ = allowed
})
}

View File

@ -1,61 +0,0 @@
package rbac_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/rbac"
)
// TestExample gives some examples on how to use the authz library.
// This serves to test syntax more than functionality.
func TestExample(t *testing.T) {
t.Parallel()
ctx := context.Background()
authorizer, err := rbac.NewAuthorizer()
require.NoError(t, err)
defaultOrg := uuid.New()
// user will become an authn object, and can even be a database.User if it
// fulfills the interface. Until then, use a placeholder.
user := subject{
UserID: "alice",
Roles: []rbac.Role{
must(rbac.RoleByName(rbac.RoleMember())),
must(rbac.RoleByName(rbac.RoleOrgAdmin(defaultOrg))),
},
}
//nolint:paralleltest
t.Run("ReadAllWorkspaces", func(t *testing.T) {
// To read all workspaces on the site
err := authorizer.Authorize(ctx, user.UserID, user.Roles, rbac.ActionRead, rbac.ResourceWorkspace.All())
var _ = err
require.Error(t, err, "this user cannot read all workspaces")
})
//nolint:paralleltest
t.Run("ReadOrgWorkspaces", func(t *testing.T) {
// To read all workspaces on the org 'default'
err := authorizer.Authorize(ctx, user.UserID, user.Roles, rbac.ActionRead, rbac.ResourceWorkspace.InOrg(defaultOrg))
require.NoError(t, err, "this user can read all org workspaces in 'default'")
})
//nolint:paralleltest
t.Run("ReadMyWorkspace", func(t *testing.T) {
// Note 'database.Workspace' could fulfill the object interface and be passed in directly
err := authorizer.Authorize(ctx, user.UserID, user.Roles, rbac.ActionRead, rbac.ResourceWorkspace.InOrg(defaultOrg).WithOwner(user.UserID))
require.NoError(t, err, "this user can their workspace")
})
}
func must[T any](value T, err error) T {
if err != nil {
panic(err)
}
return value
}

View File

@ -1,15 +0,0 @@
package rbac_test
import (
"context"
"github.com/coder/coder/coderd/rbac"
)
type fakeAuthorizer struct {
AuthFunc func(ctx context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error
}
func (f fakeAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error {
return f.AuthFunc(ctx, subjectID, roleNames, action, object)
}

147
coderd/rbac/partial.go Normal file
View File

@ -0,0 +1,147 @@
package rbac
import (
"context"
"golang.org/x/xerrors"
"github.com/open-policy-agent/opa/rego"
)
type PartialAuthorizer struct {
// partialQueries is mainly used for unit testing to assert our rego policy
// can always be compressed into a set of queries.
partialQueries *rego.PartialQueries
// input is used purely for debugging and logging.
input map[string]interface{}
// preparedQueries are the compiled set of queries after partial evaluation.
// Cache these prepared queries to avoid re-compiling the queries.
// If alwaysTrue is true, then ignore these.
preparedQueries []rego.PreparedEvalQuery
// alwaysTrue is if the subject can always perform the action on the
// resource type, regardless of the unknown fields.
alwaysTrue bool
}
func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, action Action, objectType string) (*PartialAuthorizer, error) {
input := map[string]interface{}{
"subject": authSubject{
ID: subjectID,
Roles: roles,
},
"object": map[string]string{
"type": objectType,
},
"action": action,
}
// Run the rego policy with a few unknown fields. This should simplify our
// policy to a set of queries.
partialQueries, err := rego.New(
rego.Query("true = data.authz.allow"),
rego.Module("policy.rego", policy),
rego.Unknowns([]string{
"input.object.owner",
"input.object.org_owner",
}),
rego.Input(input),
).Partial(ctx)
if err != nil {
return nil, xerrors.Errorf("prepare: %w", err)
}
pAuth := &PartialAuthorizer{
partialQueries: partialQueries,
preparedQueries: []rego.PreparedEvalQuery{},
input: input,
}
// Prepare each query to optimize the runtime when we iterate over the objects.
preparedQueries := make([]rego.PreparedEvalQuery, 0, len(partialQueries.Queries))
for _, q := range partialQueries.Queries {
if q.String() == "" {
// No more work needed. An empty query is the same as
// 'WHERE true'
// This is likely an admin. We don't even need to use rego going
// forward.
pAuth.alwaysTrue = true
preparedQueries = []rego.PreparedEvalQuery{}
break
}
results, err := rego.New(
rego.ParsedQuery(q),
).PrepareForEval(ctx)
if err != nil {
return nil, xerrors.Errorf("prepare query %s: %w", q.String(), err)
}
preparedQueries = append(preparedQueries, results)
}
pAuth.preparedQueries = preparedQueries
return pAuth, nil
}
// Authorize authorizes a single object using the partially prepared queries.
func (a PartialAuthorizer) Authorize(ctx context.Context, object Object) error {
if a.alwaysTrue {
return nil
}
// How to interpret the results of the partial queries.
// We have a list of queries that are along the lines of:
// `input.object.org_owner = ""; "me" = input.object.owner`
// `input.object.org_owner in {"feda2e52-8bf1-42ce-ad75-6c5595cb297a"} `
// All these queries are joined by an 'OR'. So we need to run through each
// query, and evaluate it.
//
// In each query, we have a list of the expressions, which should be
// all boolean expressions. In the above 1st example, there are 2.
// These expressions within a single query are `AND` together by rego.
EachQueryLoop:
for _, q := range a.preparedQueries {
// We need to eval each query with the newly known fields.
results, err := q.Eval(ctx, rego.EvalInput(map[string]interface{}{
"object": object,
}))
if err != nil {
continue EachQueryLoop
}
// If there are no results, then the query is false. This is because rego
// treats false queries as "undefined". So if any expression is false, the
// result is an empty list.
if len(results) == 0 {
continue EachQueryLoop
}
// If there is more than 1 result, that means there is more than 1 rule.
// This should not happen, because our query should always be an expression.
// If this every occurs, it is likely the original query was not an expression.
if len(results) > 1 {
continue EachQueryLoop
}
// Our queries should be simple, and should not yield any bindings.
// A binding is something like 'x := 1'. This binding as an expression is
// 'true', but in our case is unhelpful. We are not analyzing this ast to
// map bindings. So just error out. Similar to above, our queries should
// always be boolean expressions.
if len(results[0].Bindings) > 0 {
continue EachQueryLoop
}
// We have a valid set of boolean expressions! All expressions are 'AND'd
// together. This is automatic by rego, so we should not actually need to
// inspect this any further. But just in case, we will verify each expression
// did resolve to 'true'. This is purely defensive programming.
for _, exp := range results[0].Expressions {
if exp.String() != "true" {
continue EachQueryLoop
}
}
return nil
}
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), a.input, nil)
}

View File

@ -1,11 +1,31 @@
package authz
import future.keywords.in
import future.keywords.every
import future.keywords
# A great playground: https://play.openpolicyagent.org/
# TODO: Add debug instructions to do in the cli. Running really short on time, the
# playground is sufficient for now imo. In the future we can provide a tidy bash
# script for running this against predefined input.
# Helpful cli commands to debug.
# opa eval --format=pretty 'data.authz.allow = true' -d policy.rego -i input.json
# opa eval --partial --format=pretty 'data.authz.allow = true' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner -i input.json
#
# This policy is specifically constructed to compress to a set of queries if the
# object's 'owner' and 'org_owner' fields are unknown. There is no specific set
# of rules that will guarantee that this policy has this property. However, there
# are some tricks. A unit test will enforce this property, so any edits that pass
# the unit test will be ok.
#
# Tricks: (It's hard to really explain this, fiddling is required)
# 1. Do not use unknown fields in any comprehension or iteration.
# 2. Use the unknown fields as minimally as possible.
# 3. Avoid making code branches based on the value of the unknown field.
# Unknown values are like a "set" of possible values.
# (This is why rule 1 usually breaks things)
# For example:
# In the org section, we calculate the 'allow' number for all orgs, rather
# than just the input.object.org_owner. This is because if the org_owner
# changes, then we don't need to recompute any 'allow' sets. We already have
# the 'allow' for the changed value. So the answer is in a lookup table.
# The final statement 'num := allow[input.object.org_owner]' does not have
# different code branches based on the org_owner. 'num's value does, but
# that is the whole point of partial evaluation.
# bool_flip lets you assign a value to an inverted bool.
# You cannot do 'x := !false', but you can do 'x := bool_flip(false)'
@ -19,121 +39,120 @@ bool_flip(b) = flipped {
flipped = true
}
# perms_grant returns a set of boolean values {true, false}.
# True means a positive permission in the set, false is a negative permission.
# It will only return `bool_flip(perm.negate)` for permissions that affect a given
# resource_type, and action.
# The empty set is returned if no relevant permissions are found.
perms_grant(permissions) = grants {
# If there are no permissions, this value is the empty set {}.
grants := { x |
# All permissions ...
perm := permissions[_]
# Such that the permission action, and type matches
# number is a quick way to get a set of {true, false} and convert it to
# -1: {false, true} or {false}
# 0: {}
# 1: {true}
number(set) = c {
count(set) == 0
c := 0
}
number(set) = c {
false in set
c := -1
}
number(set) = c {
not false in set
set[_]
c := 1
}
# site, org, and user rules are all similar. Each rule should return a number
# from [-1, 1]. The number corresponds to "negative", "abstain", and "positive"
# for the given level. See the 'allow' rules for how these numbers are used.
default site = 0
site := num {
# allow is a set of boolean values without duplicates.
allow := { x |
# Iterate over all site permissions in all roles
perm := input.subject.roles[_].site[_]
perm.action in [input.action, "*"]
perm.resource_type in [input.object.type, "*"]
perm.resource_type in [input.object.type, "*"]
# x is either 'true' or 'false' if a matching permission exists.
x := bool_flip(perm.negate)
}
num := number(allow)
}
# Site & User are both very simple. We default both to the empty set '{}'. If no permissions are present, then the
# result is the default value.
default site = {}
site = grant {
# Boolean set for all site wide permissions.
grant = { v | # Use set comprehension to remove duplicate values
# For each role, grab the site permission.
# Find the grants on this permission list.
v = perms_grant(input.subject.roles[_].site)[_]
}
# org_members is the list of organizations the actor is apart of.
org_members := { orgID |
input.subject.roles[_].org[orgID]
}
default user = {}
user = grant {
# Only apply user permissions if the user owns the resource
# org is the same as 'site' except we need to iterate over each organization
# that the actor is a member of.
default org = 0
org := num {
allow := { id: num |
id := org_members[_]
set := { x |
perm := input.subject.roles[_].org[id][_]
perm.action in [input.action, "*"]
perm.resource_type in [input.object.type, "*"]
x := bool_flip(perm.negate)
}
num := number(set)
}
# Return only the org value of the input's org.
# The reason why we do not do this up front, is that we need to make sure
# this policy compresses down to simple queries. One way to ensure this is
# to keep unknown values out of comprehensions.
# (https://www.openpolicyagent.org/docs/latest/policy-language/#comprehensions)
num := allow[input.object.org_owner]
}
# 'org_mem' is set to true if the user is an org member
org_mem := true {
input.object.org_owner != ""
input.object.org_owner in org_members
}
# If the oject has no organization, then the user is also considered part of
# the non-existent org.
org_mem := true {
input.object.org_owner == ""
}
# User is the same as the site, except it only applies if the user owns the object and
# the user is apart of the org (if the object has an org).
default user = 0
user := num {
input.object.owner != ""
input.object.owner == input.subject.id
grant = { v |
# For each role, grab the user permissions.
# Find the grants on this permission list.
v = perms_grant(input.subject.roles[_].user)[_]
input.subject.id = input.object.owner
allow := { x |
perm := input.subject.roles[_].user[_]
perm.action in [input.action, "*"]
perm.resource_type in [input.object.type, "*"]
x := bool_flip(perm.negate)
}
num := number(allow)
}
# Organizations are more complex. If the user has no roles that specifically indicate the org_id of the object,
# then we want to block the action. This is because that means the user is not a member of the org.
# A non-member cannot access any org resources.
# org_member returns the set of permissions associated with a user if the user is a member of the
# organization
org_member = grant {
input.object.org_owner != ""
grant = { v |
v = perms_grant(input.subject.roles[_].org[input.object.org_owner])[_]
}
}
# If a user is not part of an organization, 'org_non_member' is set to true
org_non_member {
input.object.org_owner != ""
# Identify if the user is in the org
roles := input.subject.roles
every role in roles {
not role.org[input.object.org_owner]
}
}
# org is two rules that equate to the following
# if org_non_member { return {false} }
# else { org_member }
#
# It is important both rules cannot be true, as the `org` rules cannot produce multiple outputs.
default org = {}
org = set {
# We have to do !org_non_member because rego rules must evaluate to 'true'
# to have a value set.
# So we do "not not-org-member" which means "subject is in org"
not org_non_member
set = org_member
}
org = set {
org_non_member
set = {false}
}
# The allow block is quite simple. Any set with `false` cascades down in levels.
# The allow block is quite simple. Any set with `-1` cascades down in levels.
# Authorization looks for any `allow` statement that is true. Multiple can be true!
# Note that the absence of `allow` means "unauthorized".
# An explicit `"allow": true` is required.
# site allow
default allow = false
allow {
# No site wide deny
not false in site
# And all permissions are positive
site[_]
site = 1
}
# OR
# org allow
allow {
# No site or org deny
not false in site
not false in org
# And all permissions are positive
org[_]
not site = -1
org = 1
}
# OR
# user allow
allow {
# No site, org, or user deny
not false in site
not false in org
not false in user
# And all permissions are positive
user[_]
not site = -1
not org = -1
# If we are not a member of an org, and the object has an org, then we are
# not authorized. This is an "implied -1" for not being in the org.
org_mem
user = 1
}

View File

@ -273,7 +273,14 @@ func (api *API) templatesByOrganization(rw http.ResponseWriter, r *http.Request)
}
// Filter templates based on rbac permissions
templates = AuthorizeFilter(api, r, rbac.ActionRead, templates)
templates, err = AuthorizeFilter(api, r, rbac.ActionRead, templates)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching templates.",
Detail: err.Error(),
})
return
}
templateIDs := make([]uuid.UUID, 0, len(templates))

View File

@ -155,7 +155,15 @@ func (api *API) users(rw http.ResponseWriter, r *http.Request) {
return
}
users = AuthorizeFilter(api, r, rbac.ActionRead, users)
users, err = AuthorizeFilter(api, r, rbac.ActionRead, users)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching users.",
Detail: err.Error(),
})
return
}
userIDs := make([]uuid.UUID, 0, len(users))
for _, user := range users {
userIDs = append(userIDs, user.ID)
@ -489,7 +497,14 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) {
}
// Only include ones we can read from RBAC.
memberships = AuthorizeFilter(api, r, rbac.ActionRead, memberships)
memberships, err = AuthorizeFilter(api, r, rbac.ActionRead, memberships)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching memberships.",
Detail: err.Error(),
})
return
}
for _, mem := range memberships {
// If we can read the org member, include the roles.
@ -610,7 +625,14 @@ func (api *API) organizationsByUser(rw http.ResponseWriter, r *http.Request) {
}
// Only return orgs the user can read.
organizations = AuthorizeFilter(api, r, rbac.ActionRead, organizations)
organizations, err = AuthorizeFilter(api, r, rbac.ActionRead, organizations)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching organizations.",
Detail: err.Error(),
})
return
}
publicOrganizations := make([]codersdk.Organization, 0, len(organizations))
for _, organization := range organizations {

View File

@ -1196,7 +1196,6 @@ func TestPaginatedUsers(t *testing.T) {
{name: "all users", limit: 10, allUsers: allUsers},
{name: "all users", limit: 5, allUsers: allUsers},
{name: "all users", limit: 3, allUsers: allUsers},
{name: "all users", limit: 1, allUsers: allUsers},
{name: "gmail search", limit: 3, allUsers: specialUsers, opt: gmailSearch},
{name: "gmail search", limit: 7, allUsers: specialUsers, opt: gmailSearch},
{name: "username search", limit: 3, allUsers: specialUsers, opt: usernameSearch},

View File

@ -145,7 +145,14 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
}
// Only return workspaces the user can read
workspaces = AuthorizeFilter(api, r, rbac.ActionRead, workspaces)
workspaces, err = AuthorizeFilter(api, r, rbac.ActionRead, workspaces)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspaces.",
Detail: err.Error(),
})
return
}
apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, workspaces)
if err != nil {