Skip to content

chore: Update rego to be partial execution friendly #3449

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 30 commits into from
Aug 11, 2022
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions coderd/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 18 additions & 10 deletions coderd/coderd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := newRecordingAuthorizer()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
Expand Down Expand Up @@ -563,21 +563,29 @@ type authCall struct {
Object rbac.Object
}

type fakeAuthorizer struct {
type recordingAuthorizer struct {
*rbac.FakeAuthorizer
Called *authCall
AlwaysReturn error
}

func (f *fakeAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error {
f.Called = &authCall{
SubjectID: subjectID,
Roles: roleNames,
Action: action,
Object: object,
func newRecordingAuthorizer() *recordingAuthorizer {
r := &recordingAuthorizer{}
// Use the fake authorizer by rbac to handle prepared authorizers.
r.FakeAuthorizer = &rbac.FakeAuthorizer{
AuthFunc: func(ctx 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 r.AlwaysReturn
},
}
return f.AlwaysReturn
return r
}

func (f *fakeAuthorizer) reset() {
func (f *recordingAuthorizer) reset() {
f.Called = nil
}
9 changes: 8 additions & 1 deletion coderd/provisionerdaemons.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
31 changes: 31 additions & 0 deletions coderd/rbac/auth_fake.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package rbac

import "context"

type FakeAuthorizer struct {
AuthFunc func(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error
}

func (f FakeAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error {
return f.AuthFunc(ctx, subjectID, roleNames, action, object)
}

func (f FakeAuthorizer) PrepareByRoleName(_ context.Context, subjectID string, roles []string, action Action, _ string) (PreparedAuthorized, error) {
return &fakePreparedAuthorizer{
Original: f,
SubjectID: subjectID,
Roles: roles,
Action: action,
}, nil
}

type fakePreparedAuthorizer struct {
Original Authorizer
SubjectID string
Roles []string
Action Action
}

func (f fakePreparedAuthorizer) Authorize(ctx context.Context, object Object) error {
return f.Original.ByRoleName(ctx, f.SubjectID, f.Roles, f.Action, object)
}
71 changes: 48 additions & 23 deletions coderd/rbac/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, roles []string, action Action, objectType string) (PreparedAuthorized, error)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is only used internally, so seems like it might not need to be exported like this.

Copy link
Member Author

@Emyrk Emyrk Aug 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need it for my Fake authorizer to work for some unit tests. I can't fake it without an interface ☹️

recordingAuthorizer always returns false and save the auth calls so I can assert them in my tests.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any use case for exposing the PreparedAuthorized to consumers of authorization --- why not just expose the Filter() method here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wish I could, but you can't make generic methods on structs. The nice thing about the generic Filter() method is you can pass in and return the same typed slice.

If I made it a non-generic function, you'd have to take your slice of []Workspace, convert it to []rbac.Object, and then convert it back to []Workspace. I could do this all in some rbac.Authorize, but it takes a lot of slice copying I prefer to avoid.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, that sucks. Go generics continue to disappoint.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, it's really dumb because I don't see why it's not possible. Like I would like to know the objection to it.

}

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()
Expand All @@ -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)

Expand All @@ -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)
}

Expand All @@ -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)
if !results.Allowed() {
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), 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)
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)
}

if !allowedResult {
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results)
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 nil
return a.Prepare(ctx, subjectID, roles, action, objectType)
}
Loading