Skip to content

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

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
May 17, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
bed0f8f
feat: Enforce authorize call on all endpoints
Emyrk May 11, 2022
af6dc5f
Add more endpoints to the unit test
Emyrk May 12, 2022
01b2c94
Merge remote-tracking branch 'origin/main' into stevenmasley/rbac_end…
Emyrk May 12, 2022
be5b0b3
Rbac users endpoints
Emyrk May 12, 2022
970e345
Make test pass by skipping missed endpoints
Emyrk May 12, 2022
945e9fa
Fix broken tests
Emyrk May 12, 2022
fdfef88
Import order
Emyrk May 12, 2022
89a3678
PR comment fixes
Emyrk May 12, 2022
29da9aa
Merge remote-tracking branch 'origin/main' into stevenmasley/rbac_end…
Emyrk May 13, 2022
63727e0
omit another endpoint
Emyrk May 13, 2022
96a5727
Cleanup comments
Emyrk May 13, 2022
4b6c9b0
Do not leak if an organization name exists
Emyrk May 13, 2022
cd2fda7
Update comment
Emyrk May 13, 2022
62ec87e
feat: Implement authorize for each endpoint
Emyrk May 13, 2022
452c72d
Authorize per endpoint
Emyrk May 13, 2022
307f6c0
Merge remote-tracking branch 'origin/main' into stevenmasley/rbac_end…
Emyrk May 16, 2022
32af1e6
feat: Move all authorize calls into each handler
Emyrk May 16, 2022
28a099f
Delete unused code
Emyrk May 16, 2022
ff7bd81
feat: Add some perms to users
Emyrk May 16, 2022
d123b9f
Drop comment
Emyrk May 16, 2022
186eb5f
Fix 401 -> 403
Emyrk May 16, 2022
5d32d9d
Fix using User over UserData
Emyrk May 16, 2022
301d42a
Rename UserRole to RoleAssignment
Emyrk May 16, 2022
a989224
Refactor workspacesByUser
Emyrk May 16, 2022
ed9be78
Merge remote-tracking branch 'origin/main' into stevenmasley/rbac_end…
Emyrk May 16, 2022
1047391
Fix some routes
Emyrk May 16, 2022
7ad069e
Drop update User auth check from assign roles
Emyrk May 16, 2022
e68fdbf
Correct unit tests
Emyrk May 17, 2022
1a4e7e1
Unit tests use 403 vs 401
Emyrk May 17, 2022
f250fbe
401 -> 403 in unit test
Emyrk May 17, 2022
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
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) {
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) {
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 {
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 {
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() {
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) {
serverURL, err := c.URL.Parse(path)
if err != nil {
return nil, xerrors.Errorf("parse url: %w", err)
Expand Down
Loading