feat: Implement list roles & enforce authorize examples (#1273)

This commit is contained in:
Steven Masley
2022-05-03 16:10:19 -05:00
committed by GitHub
parent 0f9e30e54f
commit d0293e4d33
13 changed files with 627 additions and 5 deletions

View File

@ -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"
@ -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"
@ -48,6 +50,7 @@ type Options struct {
SecureAuthCookie bool
SSHKeygenAlgorithm gitsshkey.Algorithm
TURNServer *turnconn.Server
Authorizer *rbac.RegoAuthorizer
}
// New constructs the Coder API into an HTTP handler.
@ -61,6 +64,15 @@ 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,
}
@ -68,6 +80,13 @@ func New(options *Options) (http.Handler, func()) {
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(
@ -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)
@ -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),
@ -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)

View File

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

View File

@ -21,6 +21,7 @@ type querier interface {
DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error
DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error
GetAPIKeyByID(ctx context.Context, id string) (APIKey, error)
GetAllUserRoles(ctx context.Context, userID uuid.UUID) (GetAllUserRolesRow, error)
// GetAuditLogsBefore retrieves `limit` number of audit logs before the provided
// ID.
GetAuditLogsBefore(ctx context.Context, arg GetAuditLogsBeforeParams) ([]AuditLog, error)

View File

@ -2014,6 +2014,31 @@ func (q *sqlQuerier) UpdateTemplateVersionByID(ctx context.Context, arg UpdateTe
return err
}
const getAllUserRoles = `-- 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 = $1
`
type GetAllUserRolesRow struct {
ID uuid.UUID `db:"id" json:"id"`
Username string `db:"username" json:"username"`
Roles []string `db:"roles" json:"roles"`
}
func (q *sqlQuerier) GetAllUserRoles(ctx context.Context, userID uuid.UUID) (GetAllUserRolesRow, error) {
row := q.db.QueryRowContext(ctx, getAllUserRoles, userID)
var i GetAllUserRolesRow
err := row.Scan(&i.ID, &i.Username, pq.Array(&i.Roles))
return i, err
}
const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles

View File

@ -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
coderd/httpmw/authorize.go Normal file
View File

@ -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 {
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 {
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))
})
}
}

View File

@ -0,0 +1,131 @@
package httpmw_test
import (
"context"
"crypto/sha256"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/coder/coder/coderd/rbac"
"github.com/google/uuid"
"github.com/coder/coder/coderd/database"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/database/databasefake"
"github.com/coder/coder/coderd/httpmw"
)
func TestExtractUserRoles(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
AddUser func(db database.Store) (database.User, []string, string)
}{
{
Name: "Member",
AddUser: func(db database.Store) (database.User, []string, string) {
roles := []string{rbac.RoleMember()}
user, token := addUser(t, db, roles...)
return user, roles, token
},
},
{
Name: "Admin",
AddUser: func(db database.Store) (database.User, []string, string) {
roles := []string{rbac.RoleMember(), rbac.RoleAdmin()}
user, token := addUser(t, db, roles...)
return user, roles, token
},
},
{
Name: "OrgMember",
AddUser: func(db database.Store) (database.User, []string, string) {
roles := []string{rbac.RoleMember()}
user, token := addUser(t, db, roles...)
org, err := db.InsertOrganization(context.Background(), database.InsertOrganizationParams{
ID: uuid.New(),
Name: "testorg",
Description: "test",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
orgRoles := []string{rbac.RoleOrgMember(org.ID)}
_, err = db.InsertOrganizationMember(context.Background(), database.InsertOrganizationMemberParams{
OrganizationID: org.ID,
UserID: user.ID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Roles: orgRoles,
})
require.NoError(t, err)
return user, append(roles, orgRoles...), token
},
},
}
for _, c := range testCases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
user, expRoles, token = c.AddUser(db)
rw = httptest.NewRecorder()
rtr = chi.NewRouter()
)
rtr.Use(
httpmw.ExtractAPIKey(db, &httpmw.OAuth2Configs{}),
httpmw.ExtractUserRoles(db),
)
rtr.Get("/", func(_ http.ResponseWriter, r *http.Request) {
roles := httpmw.UserRoles(r)
require.ElementsMatch(t, user.ID, roles.ID)
require.ElementsMatch(t, expRoles, roles.Roles)
})
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Value: token,
})
rtr.ServeHTTP(rw, req)
require.Equal(t, http.StatusOK, rw.Result().StatusCode)
})
}
}
func addUser(t *testing.T, db database.Store, roles ...string) (database.User, string) {
var (
id, secret = randomAPIKeyParts()
hashed = sha256.Sum256([]byte(secret))
)
user, err := db.InsertUser(context.Background(), database.InsertUserParams{
ID: uuid.New(),
Email: "admin@email.com",
Username: "admin",
RBACRoles: roles,
})
require.NoError(t, err)
_, err = db.InsertAPIKey(context.Background(), database.InsertAPIKeyParams{
ID: id,
UserID: user.ID,
HashedSecret: hashed[:],
LastUsed: database.Now(),
ExpiresAt: database.Now().Add(time.Minute),
})
require.NoError(t, err)
return user, fmt.Sprintf("%s-%s", id, secret)
}

View File

@ -145,6 +145,49 @@ func IsOrgRole(roleName string) (string, bool) {
return "", false
}
// OrganizationRoles lists all roles that can be applied to an organization user
// in the given organization. This is the list of available roles,
// and specific to an organization.
//
// This should be a list in a database, but until then we build
// the list from the builtins.
func OrganizationRoles(organizationID uuid.UUID) []string {
var roles []string
for _, roleF := range builtInRoles {
role := roleF(organizationID.String()).Name
_, scope, err := roleSplit(role)
if err != nil {
// This should never happen
continue
}
if scope == organizationID.String() {
roles = append(roles, role)
}
}
return roles
}
// SiteRoles lists all roles that can be applied to a user.
// This is the list of available roles, and not specific to a user
//
// This should be a list in a database, but until then we build
// the list from the builtins.
func SiteRoles() []string {
var roles []string
for _, roleF := range builtInRoles {
role := roleF("random")
_, scope, err := roleSplit(role.Name)
if err != nil {
// This should never happen
continue
}
if scope == "" {
roles = append(roles, role.Name)
}
}
return roles
}
// roleName is a quick helper function to return
// role_name:scopeID
// If no scopeID is required, only 'role_name' is returned

View File

@ -1,6 +1,7 @@
package rbac_test
import (
"fmt"
"testing"
"github.com/google/uuid"
@ -60,3 +61,23 @@ func TestIsOrgRole(t *testing.T) {
})
}
}
func TestListRoles(t *testing.T) {
t.Parallel()
// If this test is ever failing, just update the list to the roles
// expected from the builtin set.
require.ElementsMatch(t, []string{
"admin",
"member",
"auditor",
},
rbac.SiteRoles())
orgID := uuid.New()
require.ElementsMatch(t, []string{
fmt.Sprintf("organization-admin:%s", orgID.String()),
fmt.Sprintf("organization-member:%s", orgID.String()),
},
rbac.OrganizationRoles(orgID))
}

View File

@ -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,

27
coderd/roles.go Normal file
View File

@ -0,0 +1,27 @@
package coderd
import (
"net/http"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/rbac"
)
// assignableSiteRoles returns all site wide roles that can be assigned.
func (*api) assignableSiteRoles(rw http.ResponseWriter, _ *http.Request) {
// TODO: @emyrk in the future, allow granular subsets of roles to be returned based on the
// role of the user.
roles := rbac.SiteRoles()
httpapi.Write(rw, http.StatusOK, roles)
}
// assignableSiteRoles returns all site wide roles that can be assigned.
func (*api) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) {
// TODO: @emyrk in the future, allow granular subsets of roles to be returned based on the
// role of the user.
organization := httpmw.OrganizationParam(r)
roles := rbac.OrganizationRoles(organization.ID)
httpapi.Write(rw, http.StatusOK, roles)
}

129
coderd/roles_test.go Normal file
View File

@ -0,0 +1,129 @@
package coderd_test
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
)
func TestListRoles(t *testing.T) {
t.Parallel()
ctx := context.Background()
client := coderdtest.New(t, nil)
// Create admin, member, and org admin
admin := coderdtest.CreateFirstUser(t, client)
member := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
orgAdmin := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
orgAdminUser, err := orgAdmin.User(ctx, codersdk.Me)
require.NoError(t, err)
// TODO: @emyrk switch this to the admin when getting non-personal users is
// supported. `client.UpdateOrganizationMemberRoles(...)`
_, err = orgAdmin.UpdateOrganizationMemberRoles(ctx, admin.OrganizationID, orgAdminUser.ID,
codersdk.UpdateRoles{
Roles: []string{rbac.RoleOrgMember(admin.OrganizationID), rbac.RoleOrgAdmin(admin.OrganizationID)},
},
)
require.NoError(t, err, "update org member roles")
otherOrg, err := client.CreateOrganization(ctx, admin.UserID, codersdk.CreateOrganizationRequest{
Name: "other",
})
require.NoError(t, err, "create org")
const unauth = "unauthorized"
const notMember = "not a member of the organization"
testCases := []struct {
Name string
Client *codersdk.Client
APICall func() ([]string, error)
ExpectedRoles []string
AuthorizedError string
}{
{
Name: "MemberListSite",
APICall: func() ([]string, error) {
x, err := member.ListSiteRoles(ctx)
return x, err
},
AuthorizedError: unauth,
},
{
Name: "OrgMemberListOrg",
APICall: func() ([]string, error) {
return member.ListOrganizationRoles(ctx, admin.OrganizationID)
},
AuthorizedError: unauth,
},
{
Name: "NonOrgMemberListOrg",
APICall: func() ([]string, error) {
return member.ListOrganizationRoles(ctx, otherOrg.ID)
},
AuthorizedError: notMember,
},
// Org admin
{
Name: "OrgAdminListSite",
APICall: func() ([]string, error) {
return orgAdmin.ListSiteRoles(ctx)
},
AuthorizedError: unauth,
},
{
Name: "OrgAdminListOrg",
APICall: func() ([]string, error) {
return orgAdmin.ListOrganizationRoles(ctx, admin.OrganizationID)
},
ExpectedRoles: rbac.OrganizationRoles(admin.OrganizationID),
},
{
Name: "OrgAdminListOtherOrg",
APICall: func() ([]string, error) {
return orgAdmin.ListOrganizationRoles(ctx, otherOrg.ID)
},
AuthorizedError: notMember,
},
// Admin
{
Name: "AdminListSite",
APICall: func() ([]string, error) {
return client.ListSiteRoles(ctx)
},
ExpectedRoles: rbac.SiteRoles(),
},
{
Name: "AdminListOrg",
APICall: func() ([]string, error) {
return client.ListOrganizationRoles(ctx, admin.OrganizationID)
},
ExpectedRoles: rbac.OrganizationRoles(admin.OrganizationID),
},
}
for _, c := range testCases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
roles, err := c.APICall()
if c.AuthorizedError != "" {
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode())
require.Contains(t, apiErr.Message, c.AuthorizedError)
} else {
require.NoError(t, err)
require.ElementsMatch(t, c.ExpectedRoles, roles)
}
})
}
}