Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat: First draft of adding authorize to an http endpoint
  • Loading branch information
Emyrk committed May 3, 2022
commit 30e203196965ebcfc0b9bfd640c326fc76870ee7
42 changes: 37 additions & 5 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/go-chi/chi/v5"
"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 @@ -22,6 +23,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 @@ -47,6 +49,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 @@ -60,13 +63,28 @@ 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,
})

authRolesMiddleware := httpmw.ExtractUserRoles(options.Database)

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

r := chi.NewRouter()
r.Route("/api/v2", func(r chi.Router) {
r.Use(
Expand Down Expand Up @@ -102,6 +120,9 @@ func New(options *Options) (http.Handler, func()) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractOrganizationParam(options.Database),
authRolesMiddleware,
// All authorize() functions will be scoped to this organization
httpmw.InOrg(httpmw.OrganizationParam),
)
r.Get("/", api.organization)
r.Get("/provisionerdaemons", api.provisionerDaemonsByOrganization)
Expand All @@ -121,6 +142,9 @@ func New(options *Options) (http.Handler, func()) {
})
})
r.Route("/members", func(r chi.Router) {
r.Route("/roles", func(r chi.Router) {
r.With(httpmw.Object(rbac.ResourceUserRole), authorize(rbac.ActionCreate, rbac.ActionDelete)).Get("/", api.assignableOrgRoles)
})
r.Route("/{user}", func(r chi.Router) {
r.Use(
httpmw.ExtractUserParam(options.Database),
Expand Down Expand Up @@ -183,20 +207,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) {
// Can create/delete all roles to view this endpoint
r.With(httpmw.Object(rbac.ResourceUserRole), authorize(rbac.ActionCreate, rbac.ActionDelete)).Get("/", api.assignableSiteRoles)
})
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
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.

40 changes: 40 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 :many
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;
160 changes: 160 additions & 0 deletions coderd/httpmw/authorize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package httpmw

import (
"context"
"fmt"
"net/http"

"github.com/google/uuid"

"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"
)

type AuthObject struct {
// WithUser sets the owner of the object to the value returned by the func
WithUser func(r *http.Request) uuid.UUID

// InOrg sets the org owner of the object to the value returned by the func
InOrg func(r *http.Request) uuid.UUID

// WithOwner sets the object id to the value returned by the func
WithOwner func(r *http.Request) uuid.UUID

// Object is that base static object the above functions can modify.
Object rbac.Object
//// Actions are the various actions the middleware will check can be done on the object.
//Actions []rbac.Action
}

func WithOwner(owner func(r *http.Request) database.User) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ao := GetAuthObject(r)
ao.WithOwner = func(r *http.Request) uuid.UUID {
return owner(r).ID
}

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

func InOrg(org func(r *http.Request) database.Organization) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ao := GetAuthObject(r)
ao.InOrg = func(r *http.Request) uuid.UUID {
return org(r).ID
}

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

// Authorize allows for static object & action authorize checking. If the object is a static object, this is an easy way
// to enforce the route.
func Authorize(logger slog.Logger, auth *rbac.RegoAuthorizer, actions ...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)
args := GetAuthObject(r)

object := args.Object
if args.InOrg != nil {
object.InOrg(args.InOrg(r))
}
if args.WithUser != nil {
object.WithOwner(args.InOrg(r).String())
}
if args.WithOwner != nil {
object.WithID(args.InOrg(r).String())
}

// Error on the first action that fails
for _, act := range actions {
err := auth.AuthorizeByRoleName(r.Context(), roles.ID.String(), roles.Roles, act, object)
if err != nil {
var internalError *rbac.UnauthorizedError
if xerrors.As(err, internalError) {
logger = logger.With(slog.F("internal", internalError.Internal()))
}
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", act),
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 GetAuthObject(r *http.Request) AuthObject {
obj, ok := r.Context().Value(authObjectKey{}).(AuthObject)
if !ok {
return AuthObject{}
}
return obj
}

func Object(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) {
ao := GetAuthObject(r)
ao.Object = object

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

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

// APIKey returns the API key from the ExtractAPIKey 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: fmt.Sprintf("roles not found", AuthCookie),
})
return
}

ctx := context.WithValue(r.Context(), userRolesKey{}, role)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}
37 changes: 37 additions & 0 deletions coderd/rbac/builtin.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,43 @@ func IsOrgRole(roleName string) (string, bool) {
return "", false
}

// ListOrgRoles lists all roles that can be applied to an organization user
// in the given organization.
// Note: This should be a list in a database, but until then we build
// the list from the builtins.
func ListOrgRoles(organizationID uuid.UUID) []string {
var roles []string
for role := range builtInRoles {
_, scope, err := roleSplit(role)
if err != nil {
// This should never happen
continue
}
if scope == organizationID.String() {
roles = append(roles, role)
}
}
return roles
}

// ListSiteRoles lists all roles that can be applied to a user.
// Note: This should be a list in a database, but until then we build
// the list from the builtins.
func ListSiteRoles() []string {
var roles []string
for role := range builtInRoles {
_, scope, err := roleSplit(role)
if err != nil {
// This should never happen
continue
}
if scope == "" {
roles = append(roles, role)
}
}
return roles
}

// roleName is a quick helper function to return
// role_name:scopeID
// If no scopeID is required, only 'role_name' is returned
Expand Down
7 changes: 7 additions & 0 deletions coderd/rbac/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ var (
Type: "template",
}

// 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.
ResourceUserRole = Object{
Type: "user_role",
}

// ResourceWildcard represents all resource types
ResourceWildcard = Object{
Type: WildcardSymbol,
Expand Down
Loading