Skip to content

feat: Implement list roles & enforce authorize examples #1273

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 16 commits into from
May 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 37 additions & 5 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/pion/webrtc/v3"
"golang.org/x/xerrors"
"google.golang.org/api/idtoken"

chitrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-chi/chi.v5"
Expand All @@ -23,6 +24,7 @@ import (
"github.com/coder/coder/coderd/gitsshkey"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/turnconn"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/site"
Expand All @@ -48,6 +50,7 @@ type Options struct {
SecureAuthCookie bool
SSHKeygenAlgorithm gitsshkey.Algorithm
TURNServer *turnconn.Server
Authorizer *rbac.RegoAuthorizer
Copy link
Member

Choose a reason for hiding this comment

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

Does this need to be exposed via options? It doesn't seem to be used outside of 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 was thinking of making it an interface that is "denyall" or something to trigger in unit tests. But for now, we don't, so I'll drop it from Options

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah wait yes. @kylecarbs this will be needed if you decide to do the rbac Authorize() check manually inside the function, instead of the middleware. So this is required.

Copy link
Member

@kylecarbs kylecarbs May 3, 2022

Choose a reason for hiding this comment

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

This could go on api instead, that way tests couldn't mistakenly pass it via Options.

Copy link
Member Author

Choose a reason for hiding this comment

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

Right now nothing else is on the api only like that.

coder/coderd/coderd.go

Lines 333 to 338 in db04d67

type api struct {
*Options
websocketWaitMutex sync.Mutex
websocketWaitGroup sync.WaitGroup
}

I don't think it'd be bad to pass one in via options 🤷‍♂️

Copy link
Member

Choose a reason for hiding this comment

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

Oh my bad, I thought there was precedent for this 🤦

If you feel like it's fine I'll defer, but I generally think it's hasty to expose a value unless it needs to be used elsewhere. It's really easy to expose a parameter, but much harder to hide one.

}

// New constructs the Coder API into an HTTP handler.
Expand All @@ -61,13 +64,29 @@ func New(options *Options) (http.Handler, func()) {
if options.APIRateLimit == 0 {
options.APIRateLimit = 512
}
if options.Authorizer == nil {
var err error
options.Authorizer, err = rbac.NewAuthorizer()
if err != nil {
// This should never happen, as the unit tests would fail if the
// default built in authorizer failed.
panic(xerrors.Errorf("rego authorize panic: %w", err))
}
}
api := &api{
Options: options,
}
apiKeyMiddleware := httpmw.ExtractAPIKey(options.Database, &httpmw.OAuth2Configs{
Github: options.GithubOAuth2Config,
})

// TODO: @emyrk we should just move this into 'ExtractAPIKey'.
authRolesMiddleware := httpmw.ExtractUserRoles(options.Database)

authorize := func(f http.HandlerFunc, actions rbac.Action) http.HandlerFunc {
return httpmw.Authorize(api.Logger, api.Authorizer, actions)(f).ServeHTTP
}

r := chi.NewRouter()

r.Use(
Expand Down Expand Up @@ -119,6 +138,7 @@ func New(options *Options) (http.Handler, func()) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractOrganizationParam(options.Database),
authRolesMiddleware,
)
r.Get("/", api.organization)
r.Get("/provisionerdaemons", api.provisionerDaemonsByOrganization)
Expand All @@ -138,6 +158,10 @@ func New(options *Options) (http.Handler, func()) {
})
})
r.Route("/members", func(r chi.Router) {
r.Route("/roles", func(r chi.Router) {
r.Use(httpmw.WithRBACObject(rbac.ResourceUserRole))
r.Get("/", authorize(api.assignableOrgRoles, rbac.ActionRead))
})
r.Route("/{user}", func(r chi.Router) {
r.Use(
httpmw.ExtractUserParam(options.Database),
Expand Down Expand Up @@ -200,20 +224,28 @@ func New(options *Options) (http.Handler, func()) {
})
})
r.Group(func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Use(
apiKeyMiddleware,
authRolesMiddleware,
)
r.Post("/", api.postUser)
r.Get("/", api.users)
// These routes query information about site wide roles.
r.Route("/roles", func(r chi.Router) {
r.Use(httpmw.WithRBACObject(rbac.ResourceUserRole))
r.Get("/", authorize(api.assignableSiteRoles, rbac.ActionRead))
})
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Get("/", api.userByName)
r.Put("/profile", api.putUserProfile)
r.Put("/suspend", api.putUserSuspend)
// TODO: @emyrk Might want to move these to a /roles group instead of /user.
// As we include more roles like org roles, it makes less sense to scope these here.
r.Put("/roles", api.putUserRoles)
r.Get("/roles", api.userRoles)
r.Get("/organizations", api.organizationsByUser)
r.Post("/organizations", api.postOrganizationsByUser)
// These roles apply to the site wide permissions.
r.Put("/roles", api.putUserRoles)
r.Get("/roles", api.userRoles)

r.Post("/keys", api.postAPIKey)
r.Route("/organizations", func(r chi.Router) {
r.Post("/", api.postOrganizationsByUser)
Expand Down
32 changes: 32 additions & 0 deletions coderd/database/databasefake/databasefake.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,38 @@ func (q *fakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams
return tmp, nil
}

func (q *fakeQuerier) GetAllUserRoles(_ context.Context, userID uuid.UUID) (database.GetAllUserRolesRow, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

var user *database.User
roles := make([]string, 0)
for _, u := range q.users {
if u.ID == userID {
u := u
roles = append(roles, u.RBACRoles...)
user = &u
break
}
}

for _, mem := range q.organizationMembers {
if mem.UserID == userID {
roles = append(roles, mem.Roles...)
}
}

if user == nil {
return database.GetAllUserRolesRow{}, sql.ErrNoRows
}

return database.GetAllUserRolesRow{
ID: userID,
Username: user.Username,
Roles: roles,
}, nil
}

func (q *fakeQuerier) GetWorkspacesByTemplateID(_ context.Context, arg database.GetWorkspacesByTemplateIDParams) ([]database.Workspace, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
Expand Down
1 change: 1 addition & 0 deletions coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions coderd/database/queries/users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,15 @@ SET
updated_at = $3
WHERE
id = $1 RETURNING *;


-- name: GetAllUserRoles :one
SELECT
-- username is returned just to help for logging purposes
id, username, array_cat(users.rbac_roles, organization_members.roles) :: text[] AS roles
FROM
users
LEFT JOIN organization_members
ON id = user_id
WHERE
id = @user_id;
122 changes: 122 additions & 0 deletions coderd/httpmw/authorize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package httpmw

import (
"context"
"net/http"

"golang.org/x/xerrors"

"cdr.dev/slog"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/rbac"
)

// 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 {
Copy link
Member

Choose a reason for hiding this comment

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

Authorize feels like action, not something that would return a handler.

What do you think about renaming this to EnforceRBAC?

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 think EnforceRBAC is also weak though. I was thinking the package httpmw provides enough context, and it does do the action Authorize().

Authorize is the correct word for what is happening, as it's not authentication. I feel EnforceRBAC doesn't indicate the object and action are included.

Another word that comes to mind is "Access". Idk, EnforceAccess, EnforcePermissions. Maybe EnforceRBAC isn't that bad, just felt odd to me at first.

Copy link
Member

Choose a reason for hiding this comment

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

Fair enough. I'm primarily trying to display that the RBAC package is being leveraged when calling this handle. Enforce is a bit sketchy too.

While it is authorizing, I'm nervous that this will get conflated with authentication really easily.

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 this is classic authorization vs authentication. If you aren't familiar with it, it's easy to mix up.

Copy link
Member

Choose a reason for hiding this comment

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

Agreed agreed

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
roles := UserRoles(r)
object := rbacObject(r)

if object.Type == "" {
panic("developer error: auth object has no type")
}

// First extract the object's owner and organization if present.
unknownOrg := r.Context().Value(organizationParamContextKey{})
if organization, castOK := unknownOrg.(database.Organization); unknownOrg != nil {
if !castOK {
panic("developer error: organization param middleware not provided for authorize")
}
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")
}
object = object.WithOwner(owner.ID.String())
}

err := auth.AuthorizeByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object)
if err != nil {
internalError := new(rbac.UnauthorizedError)
if xerrors.As(err, internalError) {
logger = logger.With(slog.F("internal", internalError.Internal()))
}
// Log information for debugging. This will be very helpful
// in the early days if we over secure endpoints.
logger.Warn(r.Context(), "unauthorized",
slog.F("roles", roles.Roles),
slog.F("user_id", roles.ID),
slog.F("username", roles.Username),
slog.F("route", r.URL.Path),
slog.F("action", action),
slog.F("object", object),
)
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: err.Error(),
})
return
}
next.ServeHTTP(rw, r)
})
}
}

type authObjectKey struct{}

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

// 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 {
Copy link
Member

Choose a reason for hiding this comment

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

It might be confusing that this is called WithRBACObject, but the values are rbac.ResourceX. It could be helpful to make these the same names!

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), authObjectKey{}, object)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}

// User roles are the 'subject' field of Authorize()
type userRolesKey struct{}

// UserRoles returns the API key from the ExtractUserRoles handler.
func UserRoles(r *http.Request) database.GetAllUserRolesRow {
apiKey, ok := r.Context().Value(userRolesKey{}).(database.GetAllUserRolesRow)
if !ok {
panic("developer error: user roles middleware not provided")
}
return apiKey
}

// ExtractUserRoles requires authentication using a valid API key.
func ExtractUserRoles(db database.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
apiKey := APIKey(r)
role, err := db.GetAllUserRoles(r.Context(), apiKey.UserID)
if err != nil {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: "roles not found",
})
return
}

ctx := context.WithValue(r.Context(), userRolesKey{}, role)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}
Loading