Skip to content

feat: Rbac more coderd endpoints, unit test to confirm #1406

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

Closed
wants to merge 13 commits into from
Next Next commit
feat: Enforce authorize call on all endpoints
- Make 'request()' exported for running custom requests
  • Loading branch information
Emyrk committed May 11, 2022
commit bed0f8f120ea094776f4a4651ea5e5bb30f86e62
18 changes: 12 additions & 6 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ type Options struct {
SecureAuthCookie bool
SSHKeygenAlgorithm gitsshkey.Algorithm
TURNServer *turnconn.Server
Authorizer *rbac.RegoAuthorizer
Authorizer rbac.Authorizer
}

// New constructs the Coder API into an HTTP handler.
Expand Down Expand Up @@ -127,18 +127,20 @@ func New(options *Options) (http.Handler, func()) {
r.Route("/files", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
authRolesMiddleware,
// This number is arbitrary, but reading/writing
// file content is expensive so it should be small.
httpmw.RateLimitPerMinute(12),
httpmw.WithRBACObject(rbac.ResourceTypeFile),
)
r.Get("/{hash}", api.fileByHash)
r.Post("/", api.postFile)
})
r.Route("/organizations/{organization}", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractOrganizationParam(options.Database),
authRolesMiddleware,
httpmw.ExtractOrganizationParam(options.Database),
)
r.Get("/", api.organization)
r.Get("/provisionerdaemons", api.provisionerDaemonsByOrganization)
Expand All @@ -149,12 +151,16 @@ func New(options *Options) (http.Handler, func()) {
r.Get("/{templatename}", api.templateByOrganizationAndName)
})
r.Route("/workspaces", func(r chi.Router) {
r.Post("/", api.postWorkspacesByOrganization)
r.Get("/", api.workspacesByOrganization)
r.Use(httpmw.WithRBACObject(rbac.ResourceWorkspace))
// Posting a workspace is inherently owned by the api key creating it.
r.With(httpmw.WithAPIKeyAsOwner()).
Post("/", authorize(api.postWorkspacesByOrganization, rbac.ActionCreate))
r.Get("/", authorize(api.workspacesByOrganization, rbac.ActionRead))
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Get("/{workspace}", api.workspaceByOwnerAndName)
r.Get("/", api.workspacesByOwner)
// TODO: @emyrk add the resource id to this authorize.
r.Get("/{workspace}", authorize(api.workspaceByOwnerAndName, rbac.ActionRead))
r.Get("/", authorize(api.workspacesByOwner, rbac.ActionRead))
})
})
r.Route("/members", func(r chi.Router) {
Expand Down
78 changes: 76 additions & 2 deletions coderd/coderd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ package coderd_test

import (
"context"
"net/http"
"testing"

"go.uber.org/goleak"

"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"

"github.com/coder/coder/buildinfo"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/rbac"
)

func TestMain(m *testing.M) {
Expand All @@ -24,3 +27,74 @@ func TestBuildInfo(t *testing.T) {
require.Equal(t, buildinfo.ExternalURL(), buildInfo.ExternalURL, "external URL")
require.Equal(t, buildinfo.Version(), buildInfo.Version, "version")
}

// TestAuthorizeAllEndpoints will check `authorize` is called on every endpoint registered.
func TestAuthorizeAllEndpoints(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

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

praise: This is a really useful check to have. Nice one.

Copy link
Contributor

Choose a reason for hiding this comment

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

💯

t.Parallel()

// skipRoutes allows skipping routes from being checked.
type routeCheck struct {
NoAuthorize bool
}
assertRoute := map[string]routeCheck{
"GET:/api/v2": {NoAuthorize: true},
"GET:/api/v2/buildinfo": {NoAuthorize: true},
}

authorizer := &fakeAuthorizer{}
srv, client := coderdtest.NewMemoryCoderd(t, &coderdtest.Options{
Authorizer: authorizer,
})
admin := coderdtest.CreateFirstUser(t, client)
var _ = admin

c := srv.Config.Handler.(*chi.Mux)
err := chi.Walk(c, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
name := method + ":" + route
t.Run(name, func(t *testing.T) {
authorizer.reset()
routeAssertions, ok := assertRoute[name]
if !ok {
// By default, all omitted routes check for just "authorize" called
routeAssertions = routeCheck{}
}

resp, err := client.Request(context.Background(), method, route, nil)
require.NoError(t, err, "do req")
_ = resp.Body.Close()

if !routeAssertions.NoAuthorize {
assert.NotNil(t, authorizer.Called, "authorizer expected")
} else {
assert.Nil(t, authorizer.Called, "authorize not expected")
}
})
return nil
})
require.NoError(t, err)
}

type authCall struct {
SubjectID string
Roles []string
Action rbac.Action
Object rbac.Object
}

type fakeAuthorizer struct {
Called *authCall
}

func (f *fakeAuthorizer) AuthorizeByRoleName(ctx context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error {
f.Called = &authCall{
SubjectID: subjectID,
Roles: roleNames,
Action: action,
Object: object,
}
return nil
}

func (f *fakeAuthorizer) reset() {
f.Called = nil
}
17 changes: 13 additions & 4 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import (
"testing"
"time"

"github.com/coder/coder/coderd/rbac"

"cloud.google.com/go/compute/metadata"
"github.com/fullsailor/pkcs7"
"github.com/golang-jwt/jwt"
Expand Down Expand Up @@ -57,11 +59,10 @@ type Options struct {
GoogleTokenValidator *idtoken.Validator
SSHKeygenAlgorithm gitsshkey.Algorithm
APIRateLimit int
Authorizer rbac.Authorizer
}

// New constructs an in-memory coderd instance and returns
// the connected client.
func New(t *testing.T, options *Options) *codersdk.Client {
func NewMemoryCoderd(t *testing.T, options *Options) (*httptest.Server, *codersdk.Client) {
Copy link
Member

Choose a reason for hiding this comment

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

I'd tried something before where I returned a coderdtest.API struct to which we could add extra fields, but it touched basically everything. This is probably a way nicer/better way of doing it.

Also, having control of the test server is definitely something we want to have available in unit tests.

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, I really didn't want to touch much. I just needed access to the server. If you pass things into the Options, you do have some of those fields you'd include in that API type.

Copy link
Member

Choose a reason for hiding this comment

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

Because the test consuming this is just using the HTTP handler returned and not the actual server, it might be clearer to just call coderd.New directly in that test instead of changing this.

Considering none of our other tests have required access to the HTTP server object, it seems like that could be cleaner.

Copy link
Member Author

Choose a reason for hiding this comment

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

The thing is, if I don't use the coderdtest for the setup, I hit a lot of random issues with endpoints because the underlying infrastructure is not implemented. I do use the server, as I am using the sdk Request function to ensure the proper auth, and I have to do setup for the unit tests.

I will need to make a template & workspace for certain endpoints to not return 404's.

It is much easier to know the coderd I am interacting with works when I use coderdtest.

Copy link
Member

Choose a reason for hiding this comment

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

The only thing it should need to check for permissions is Database and Authorize!

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, but the unit test calls all possible routes, and checks if authorize was called since I fake the Authorizer and can check if it was called. So I need to setup all the routes on a server.

I could do this just on the handler returned by coderd.New(), but it does make it kinda annoying to make the setup. I need to make a first user for the api key middleware to work, which requires a client for all the helper funcs. I need to make an organization for some of the routes. I probably need to make a template & workspace too. All this setup means if I only have the DB, I have to make the DB calls directly. It's much easier to to just the sdk.

So I could probably make it work without calling codertest.New(), but I don't see the value in doing all this work to prop up all this "fake" stuff, when I can just use coderdtest.New(). It does all that for me and I have the same tools as all the other tests 🤷.

The only downside is that I need to expose the server to the unit test to grab the handler to walk it.

if options == nil {
options = &Options{}
}
Expand Down Expand Up @@ -129,6 +130,7 @@ func New(t *testing.T, options *Options) *codersdk.Client {
SSHKeygenAlgorithm: options.SSHKeygenAlgorithm,
TURNServer: turnServer,
APIRateLimit: options.APIRateLimit,
Authorizer: options.Authorizer,
})
t.Cleanup(func() {
cancelFunc()
Expand All @@ -137,7 +139,14 @@ func New(t *testing.T, options *Options) *codersdk.Client {
closeWait()
})

return codersdk.New(serverURL)
return srv, codersdk.New(serverURL)
}

// New constructs an in-memory coderd instance and returns
// the connected client.
func New(t *testing.T, options *Options) *codersdk.Client {
_, cli := NewMemoryCoderd(t, options)
return cli
}

// NewProvisionerDaemon launches a provisionerd instance configured to work
Expand Down
67 changes: 57 additions & 10 deletions coderd/httpmw/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"net/http"

"github.com/google/uuid"

"golang.org/x/xerrors"

"cdr.dev/slog"
Expand All @@ -15,11 +17,12 @@ import (
// Authorize will enforce if the user roles can complete the action on the AuthObject.
// The organization and owner are found using the ExtractOrganization and
// ExtractUser middleware if present.
func Authorize(logger slog.Logger, auth *rbac.RegoAuthorizer, action rbac.Action) func(http.Handler) http.Handler {
func Authorize(logger slog.Logger, auth rbac.Authorizer, action rbac.Action) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
roles := UserRoles(r)
object := rbacObject(r)
authObject := rbacObject(r)
object := authObject.Object

if object.Type == "" {
panic("developer error: auth object has no type")
Expand All @@ -34,12 +37,18 @@ func Authorize(logger slog.Logger, auth *rbac.RegoAuthorizer, action rbac.Action
object = object.InOrg(organization.ID)
}

unknownOwner := r.Context().Value(userParamContextKey{})
if owner, castOK := unknownOwner.(database.User); unknownOwner != nil {
if !castOK {
panic("developer error: user param middleware not provided for authorize")
if authObject.WithOwner != nil {
Copy link
Contributor

Choose a reason for hiding this comment

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

This design seems to leave us in a place where we're calling Authorize before we've actually constructed the full object, so the first part of this method is collecting bits of information about the object itself. Is there a way that we can construct the object first, then call authorize?

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 don't think I follow this comment.

This Authorize middleware builds the object from the following:

  • First grabs the object set by httpmw.WithRBACObject(...). This sets all the static fields of the rbac object to check (likely just the type)
  • Any endpoint that has {organization} or {user} we assume the resource is owned by the organization and/or user in the url params. (This is currently mostly always true)
  • In some cases the above is not true, you can use httpmw.WithOwner(...) to set the owner of the object dynamically. You cannot set it statically because it is dependent on the request's context. So it is a function that takes the request, and outputs some uuid.

The actual auth call is done at the bottom:

err := auth.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object)

owner := authObject.WithOwner(r)
object = object.WithOwner(owner.String())
} else {
// Attempt to find the resource owner id
unknownOwner := r.Context().Value(userParamContextKey{})
if owner, castOK := unknownOwner.(database.User); unknownOwner != nil {
Copy link
Contributor

Choose a reason for hiding this comment

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

it's very strange to me to have owner, castOK := unknownOwner.(database.User) appear in this if statement when neither value is evaluated by the if. I think it would be less confusing on the next line inside the block

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 see how it is confusing, I can rework this logic flow

if !castOK {
panic("developer error: user param middleware not provided for authorize")
}
object = object.WithOwner(owner.ID.String())
}
object = object.WithOwner(owner.ID.String())
}

err := auth.AuthorizeByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object)
Expand Down Expand Up @@ -70,21 +79,59 @@ func Authorize(logger slog.Logger, auth *rbac.RegoAuthorizer, action rbac.Action

type authObjectKey struct{}

type AuthObject struct {
Object rbac.Object

WithOwner func(r *http.Request) uuid.UUID
}

// APIKey returns the API key from the ExtractAPIKey handler.
func rbacObject(r *http.Request) rbac.Object {
obj, ok := r.Context().Value(authObjectKey{}).(rbac.Object)
func rbacObject(r *http.Request) AuthObject {
obj, ok := r.Context().Value(authObjectKey{}).(AuthObject)
if !ok {
panic("developer error: auth object middleware not provided")
}
return obj
}

func WithAPIKeyAsOwner() func(http.Handler) http.Handler {
return WithOwner(func(r *http.Request) uuid.UUID {
key := APIKey(r)
return key.UserID
})
}

// WithOwner sets the object owner for 'Authorize()' for all routes handled
// by this middleware.
func WithOwner(withOwner func(r *http.Request) uuid.UUID) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
obj, ok := r.Context().Value(authObjectKey{}).(AuthObject)
if ok {
obj.WithOwner = withOwner
} else {
obj = AuthObject{WithOwner: withOwner}
}

ctx := context.WithValue(r.Context(), authObjectKey{}, obj)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}

// WithRBACObject sets the object for 'Authorize()' for all routes handled
// by this middleware. The important field to set is 'Type'
func WithRBACObject(object rbac.Object) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), authObjectKey{}, object)
obj, ok := r.Context().Value(authObjectKey{}).(AuthObject)
if ok {
obj.Object = object
} else {
obj = AuthObject{Object: object}
}

ctx := context.WithValue(r.Context(), authObjectKey{}, obj)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
Expand Down
4 changes: 3 additions & 1 deletion coderd/httpmw/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"reflect"

"golang.org/x/oauth2"

Expand Down Expand Up @@ -46,7 +47,8 @@ func OAuth2(r *http.Request) OAuth2State {
func ExtractOAuth2(config OAuth2Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if config == nil {
// Interfaces can hold a nil value
if config == nil || reflect.ValueOf(config).IsNil() {
Comment on lines +50 to +51
Copy link
Member Author

Choose a reason for hiding this comment

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

This was a bug that was causing panics in unit tests.

Comment on lines +50 to +51
Copy link
Member

Choose a reason for hiding this comment

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

I'm confused why the reflect.ValueOf solves this, but the nil check doesn't.

Copy link
Member Author

Choose a reason for hiding this comment

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

Because of how we pass the nil value. We pass it from a struct, and Go is being Go.

See this:
https://go.dev/play/p/9mgJ58zg7kn

It sucks, I was trying to rework it to fix it, and it's kinda a PIA

Copy link
Member Author

Choose a reason for hiding this comment

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

One way we can fix this is avoid passing in nil in the first place.

Copy link
Member

Choose a reason for hiding this comment

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

Very interesting

Copy link
Member Author

Choose a reason for hiding this comment

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

r.Route("/github", func(r chi.Router) {
	if options.GithubOAuth2Config != nil {
		r.Use(httpmw.ExtractOAuth2(options.GithubOAuth2Config))
		r.Get("/callback", api.userOAuth2Github)
	}
})

httpapi.Write(rw, http.StatusPreconditionRequired, httpapi.Response{
Message: "The oauth2 method requested is not configured!",
})
Expand Down
4 changes: 4 additions & 0 deletions coderd/rbac/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import (
"github.com/open-policy-agent/opa/rego"
)

type Authorizer interface {
AuthorizeByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error
}

// RegoAuthorizer will use a prepared rego query for performing authorize()
type RegoAuthorizer struct {
query rego.PreparedEvalQuery
Expand Down
16 changes: 16 additions & 0 deletions coderd/rbac/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const WildcardSymbol = "*"
// Resources are just typed objects. Making resources this way allows directly
// passing them into an Authorize function and use the chaining api.
var (
// ResourceWorkspace CRUD. Org + User owner
// create/delete = make or delete workspaces
// read = access workspace
// update = edit workspace variables
ResourceWorkspace = Object{
Type: "workspace",
}
Expand All @@ -17,6 +21,18 @@ var (
Type: "template",
}

ResourceTypeFile = Object{
Type: "file",
}

// ResourceOrganization CRUD. Org owner
// create/delete = make or delete organizations
// read = view org information
// update = ??
ResourceOrganization = Object{
Type: "organization",
}

// ResourceUserRole might be expanded later to allow more granular permissions
// to modifying roles. For now, this covers all possible roles, so having this permission
// allows granting/deleting **ALL** roles.
Expand Down
2 changes: 1 addition & 1 deletion codersdk/buildinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type BuildInfoResponse struct {

// BuildInfo returns build information for this instance of Coder.
func (c *Client) BuildInfo(ctx context.Context) (BuildInfoResponse, error) {
res, err := c.request(ctx, http.MethodGet, "/api/v2/buildinfo", nil)
res, err := c.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil)
if err != nil {
return BuildInfoResponse{}, err
}
Expand Down
4 changes: 2 additions & 2 deletions codersdk/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ type Client struct {

type requestOption func(*http.Request)

// request performs an HTTP request with the body provided.
// Request performs an HTTP request with the body provided.
// The caller is responsible for closing the response body.
func (c *Client) request(ctx context.Context, method, path string, body interface{}, opts ...requestOption) (*http.Response, error) {
func (c *Client) Request(ctx context.Context, method, path string, body interface{}, opts ...requestOption) (*http.Response, error) {
Comment on lines +38 to +40
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 had to export this to use it generically in my unit test.

Copy link
Contributor

Choose a reason for hiding this comment

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

This brings sadness

Copy link
Member Author

Choose a reason for hiding this comment

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

Could be helpful though as now we can make unit tests that send malformed payloads 🤷‍♂️

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not opposed to having both in package and out of package tests for different kinds of testing (black box/white box)

serverURL, err := c.URL.Parse(path)
if err != nil {
return nil, xerrors.Errorf("parse url: %w", err)
Expand Down
Loading