mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
feat: Implement list roles & enforce authorize examples (#1273)
This commit is contained in:
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
122
coderd/httpmw/authorize.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
131
coderd/httpmw/authorize_test.go
Normal file
131
coderd/httpmw/authorize_test.go
Normal 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)
|
||||
}
|
@ -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
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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
27
coderd/roles.go
Normal 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
129
coderd/roles_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user