mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: remove site wide perms from creating a workspace (#17296)
Creating a workspace required `read` on site wide `user`. Only organization permissions should be required.
This commit is contained in:
+62
-52
@@ -1146,64 +1146,74 @@ func New(options *Options) *API {
|
||||
r.Get("/", api.AssignableSiteRoles)
|
||||
})
|
||||
r.Route("/{user}", func(r chi.Router) {
|
||||
r.Use(httpmw.ExtractUserParam(options.Database))
|
||||
r.Post("/convert-login", api.postConvertLoginType)
|
||||
r.Delete("/", api.deleteUser)
|
||||
r.Get("/", api.userByName)
|
||||
r.Get("/autofill-parameters", api.userAutofillParameters)
|
||||
r.Get("/login-type", api.userLoginType)
|
||||
r.Put("/profile", api.putUserProfile)
|
||||
r.Route("/status", func(r chi.Router) {
|
||||
r.Put("/suspend", api.putSuspendUserAccount())
|
||||
r.Put("/activate", api.putActivateUserAccount())
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(httpmw.ExtractUserParamOptional(options.Database))
|
||||
// Creating workspaces does not require permissions on the user, only the
|
||||
// organization member. This endpoint should match the authz story of
|
||||
// postWorkspacesByOrganization
|
||||
r.Post("/workspaces", api.postUserWorkspaces)
|
||||
})
|
||||
r.Get("/appearance", api.userAppearanceSettings)
|
||||
r.Put("/appearance", api.putUserAppearanceSettings)
|
||||
r.Route("/password", func(r chi.Router) {
|
||||
r.Use(httpmw.RateLimit(options.LoginRateLimit, time.Minute))
|
||||
r.Put("/", api.putUserPassword)
|
||||
})
|
||||
// These roles apply to the site wide permissions.
|
||||
r.Put("/roles", api.putUserRoles)
|
||||
r.Get("/roles", api.userRoles)
|
||||
|
||||
r.Route("/keys", func(r chi.Router) {
|
||||
r.Post("/", api.postAPIKey)
|
||||
r.Route("/tokens", func(r chi.Router) {
|
||||
r.Post("/", api.postToken)
|
||||
r.Get("/", api.tokens)
|
||||
r.Get("/tokenconfig", api.tokenConfig)
|
||||
r.Route("/{keyname}", func(r chi.Router) {
|
||||
r.Get("/", api.apiKeyByName)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(httpmw.ExtractUserParam(options.Database))
|
||||
|
||||
r.Post("/convert-login", api.postConvertLoginType)
|
||||
r.Delete("/", api.deleteUser)
|
||||
r.Get("/", api.userByName)
|
||||
r.Get("/autofill-parameters", api.userAutofillParameters)
|
||||
r.Get("/login-type", api.userLoginType)
|
||||
r.Put("/profile", api.putUserProfile)
|
||||
r.Route("/status", func(r chi.Router) {
|
||||
r.Put("/suspend", api.putSuspendUserAccount())
|
||||
r.Put("/activate", api.putActivateUserAccount())
|
||||
})
|
||||
r.Get("/appearance", api.userAppearanceSettings)
|
||||
r.Put("/appearance", api.putUserAppearanceSettings)
|
||||
r.Route("/password", func(r chi.Router) {
|
||||
r.Use(httpmw.RateLimit(options.LoginRateLimit, time.Minute))
|
||||
r.Put("/", api.putUserPassword)
|
||||
})
|
||||
// These roles apply to the site wide permissions.
|
||||
r.Put("/roles", api.putUserRoles)
|
||||
r.Get("/roles", api.userRoles)
|
||||
|
||||
r.Route("/keys", func(r chi.Router) {
|
||||
r.Post("/", api.postAPIKey)
|
||||
r.Route("/tokens", func(r chi.Router) {
|
||||
r.Post("/", api.postToken)
|
||||
r.Get("/", api.tokens)
|
||||
r.Get("/tokenconfig", api.tokenConfig)
|
||||
r.Route("/{keyname}", func(r chi.Router) {
|
||||
r.Get("/", api.apiKeyByName)
|
||||
})
|
||||
})
|
||||
r.Route("/{keyid}", func(r chi.Router) {
|
||||
r.Get("/", api.apiKeyByID)
|
||||
r.Delete("/", api.deleteAPIKey)
|
||||
})
|
||||
})
|
||||
r.Route("/{keyid}", func(r chi.Router) {
|
||||
r.Get("/", api.apiKeyByID)
|
||||
r.Delete("/", api.deleteAPIKey)
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/organizations", func(r chi.Router) {
|
||||
r.Get("/", api.organizationsByUser)
|
||||
r.Get("/{organizationname}", api.organizationByUserAndName)
|
||||
})
|
||||
r.Post("/workspaces", api.postUserWorkspaces)
|
||||
r.Route("/workspace/{workspacename}", func(r chi.Router) {
|
||||
r.Get("/", api.workspaceByOwnerAndName)
|
||||
r.Get("/builds/{buildnumber}", api.workspaceBuildByBuildNumber)
|
||||
})
|
||||
r.Get("/gitsshkey", api.gitSSHKey)
|
||||
r.Put("/gitsshkey", api.regenerateGitSSHKey)
|
||||
r.Route("/notifications", func(r chi.Router) {
|
||||
r.Route("/preferences", func(r chi.Router) {
|
||||
r.Get("/", api.userNotificationPreferences)
|
||||
r.Put("/", api.putUserNotificationPreferences)
|
||||
r.Route("/organizations", func(r chi.Router) {
|
||||
r.Get("/", api.organizationsByUser)
|
||||
r.Get("/{organizationname}", api.organizationByUserAndName)
|
||||
})
|
||||
r.Route("/workspace/{workspacename}", func(r chi.Router) {
|
||||
r.Get("/", api.workspaceByOwnerAndName)
|
||||
r.Get("/builds/{buildnumber}", api.workspaceBuildByBuildNumber)
|
||||
})
|
||||
r.Get("/gitsshkey", api.gitSSHKey)
|
||||
r.Put("/gitsshkey", api.regenerateGitSSHKey)
|
||||
r.Route("/notifications", func(r chi.Router) {
|
||||
r.Route("/preferences", func(r chi.Router) {
|
||||
r.Get("/", api.userNotificationPreferences)
|
||||
r.Put("/", api.putUserNotificationPreferences)
|
||||
})
|
||||
})
|
||||
r.Route("/webpush", func(r chi.Router) {
|
||||
r.Post("/subscription", api.postUserWebpushSubscription)
|
||||
r.Delete("/subscription", api.deleteUserWebpushSubscription)
|
||||
r.Post("/test", api.postUserPushNotificationTest)
|
||||
})
|
||||
})
|
||||
r.Route("/webpush", func(r chi.Router) {
|
||||
r.Post("/subscription", api.postUserWebpushSubscription)
|
||||
r.Delete("/subscription", api.deleteUserWebpushSubscription)
|
||||
r.Post("/test", api.postUserPushNotificationTest)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,7 +81,7 @@ func AssertRBAC(t *testing.T, api *coderd.API, client *codersdk.Client) RBACAsse
|
||||
// Note that duplicate rbac calls are handled by the rbac.Cacher(), but
|
||||
// will be recorded twice. So AllCalls() returns calls regardless if they
|
||||
// were returned from the cached or not.
|
||||
func (a RBACAsserter) AllCalls() []AuthCall {
|
||||
func (a RBACAsserter) AllCalls() AuthCalls {
|
||||
return a.Recorder.AllCalls(&a.Subject)
|
||||
}
|
||||
|
||||
@@ -140,8 +140,11 @@ func (a RBACAsserter) Reset() RBACAsserter {
|
||||
return a
|
||||
}
|
||||
|
||||
type AuthCalls []AuthCall
|
||||
|
||||
type AuthCall struct {
|
||||
rbac.AuthCall
|
||||
Err error
|
||||
|
||||
asserted bool
|
||||
// callers is a small stack trace for debugging.
|
||||
@@ -252,7 +255,7 @@ func (r *RecordingAuthorizer) AssertActor(t *testing.T, actor rbac.Subject, did
|
||||
}
|
||||
|
||||
// recordAuthorize is the internal method that records the Authorize() call.
|
||||
func (r *RecordingAuthorizer) recordAuthorize(subject rbac.Subject, action policy.Action, object rbac.Object) {
|
||||
func (r *RecordingAuthorizer) recordAuthorize(subject rbac.Subject, action policy.Action, object rbac.Object, authzErr error) {
|
||||
r.Lock()
|
||||
defer r.Unlock()
|
||||
|
||||
@@ -262,6 +265,7 @@ func (r *RecordingAuthorizer) recordAuthorize(subject rbac.Subject, action polic
|
||||
Action: action,
|
||||
Object: object,
|
||||
},
|
||||
Err: authzErr,
|
||||
callers: []string{
|
||||
// This is a decent stack trace for debugging.
|
||||
// Some dbauthz calls are a bit nested, so we skip a few.
|
||||
@@ -288,11 +292,12 @@ func caller(skip int) string {
|
||||
}
|
||||
|
||||
func (r *RecordingAuthorizer) Authorize(ctx context.Context, subject rbac.Subject, action policy.Action, object rbac.Object) error {
|
||||
r.recordAuthorize(subject, action, object)
|
||||
if r.Wrapped == nil {
|
||||
panic("Developer error: RecordingAuthorizer.Wrapped is nil")
|
||||
}
|
||||
return r.Wrapped.Authorize(ctx, subject, action, object)
|
||||
authzErr := r.Wrapped.Authorize(ctx, subject, action, object)
|
||||
r.recordAuthorize(subject, action, object, authzErr)
|
||||
return authzErr
|
||||
}
|
||||
|
||||
func (r *RecordingAuthorizer) Prepare(ctx context.Context, subject rbac.Subject, action policy.Action, objectType string) (rbac.PreparedAuthorized, error) {
|
||||
@@ -339,10 +344,11 @@ func (s *PreparedRecorder) Authorize(ctx context.Context, object rbac.Object) er
|
||||
s.rw.Lock()
|
||||
defer s.rw.Unlock()
|
||||
|
||||
authzErr := s.prepped.Authorize(ctx, object)
|
||||
if !s.usingSQL {
|
||||
s.rec.recordAuthorize(s.subject, s.action, object)
|
||||
s.rec.recordAuthorize(s.subject, s.action, object, authzErr)
|
||||
}
|
||||
return s.prepped.Authorize(ctx, object)
|
||||
return authzErr
|
||||
}
|
||||
|
||||
func (s *PreparedRecorder) CompileToSQL(ctx context.Context, cfg regosql.ConvertConfig) (string, error) {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package httpapi
|
||||
|
||||
import "net/http"
|
||||
|
||||
// NoopResponseWriter is a response writer that does nothing.
|
||||
type NoopResponseWriter struct{}
|
||||
|
||||
func (NoopResponseWriter) Header() http.Header { return http.Header{} }
|
||||
func (NoopResponseWriter) Write(p []byte) (int, error) { return len(p), nil }
|
||||
func (NoopResponseWriter) WriteHeader(int) {}
|
||||
@@ -117,7 +117,7 @@ func ExtractOrganizationMemberParam(db database.Store) func(http.Handler) http.H
|
||||
// very important that we do not add the User object to the request context or otherwise
|
||||
// leak it to the API handler.
|
||||
// nolint:gocritic
|
||||
user, ok := extractUserContext(dbauthz.AsSystemRestricted(ctx), db, rw, r)
|
||||
user, ok := ExtractUserContext(dbauthz.AsSystemRestricted(ctx), db, rw, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -31,13 +31,18 @@ func UserParam(r *http.Request) database.User {
|
||||
return user
|
||||
}
|
||||
|
||||
func UserParamOptional(r *http.Request) (database.User, bool) {
|
||||
user, ok := r.Context().Value(userParamContextKey{}).(database.User)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
// ExtractUserParam extracts a user from an ID/username in the {user} URL
|
||||
// parameter.
|
||||
func ExtractUserParam(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) {
|
||||
ctx := r.Context()
|
||||
user, ok := extractUserContext(ctx, db, rw, r)
|
||||
user, ok := ExtractUserContext(ctx, db, rw, r)
|
||||
if !ok {
|
||||
// response already handled
|
||||
return
|
||||
@@ -48,15 +53,31 @@ func ExtractUserParam(db database.Store) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// extractUserContext queries the database for the parameterized `{user}` from the request URL.
|
||||
func extractUserContext(ctx context.Context, db database.Store, rw http.ResponseWriter, r *http.Request) (user database.User, ok bool) {
|
||||
// ExtractUserParamOptional does not fail if no user is present.
|
||||
func ExtractUserParamOptional(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) {
|
||||
ctx := r.Context()
|
||||
|
||||
user, ok := ExtractUserContext(ctx, db, &httpapi.NoopResponseWriter{}, r)
|
||||
if ok {
|
||||
ctx = context.WithValue(ctx, userParamContextKey{}, user)
|
||||
}
|
||||
|
||||
next.ServeHTTP(rw, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractUserContext queries the database for the parameterized `{user}` from the request URL.
|
||||
func ExtractUserContext(ctx context.Context, db database.Store, rw http.ResponseWriter, r *http.Request) (user database.User, ok bool) {
|
||||
// userQuery is either a uuid, a username, or 'me'
|
||||
userQuery := chi.URLParam(r, "user")
|
||||
if userQuery == "" {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "\"user\" must be provided.",
|
||||
})
|
||||
return database.User{}, true
|
||||
return database.User{}, false
|
||||
}
|
||||
|
||||
if userQuery == "me" {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
cstrings "github.com/coder/coder/v2/coderd/util/strings"
|
||||
)
|
||||
|
||||
// ResourceUserObject is a helper function to create a user object for authz checks.
|
||||
@@ -37,6 +41,25 @@ type Object struct {
|
||||
ACLGroupList map[string][]policy.Action ` json:"acl_group_list"`
|
||||
}
|
||||
|
||||
// String is not perfect, but decent enough for human display
|
||||
func (z Object) String() string {
|
||||
var parts []string
|
||||
if z.OrgID != "" {
|
||||
parts = append(parts, fmt.Sprintf("org:%s", cstrings.Truncate(z.OrgID, 4)))
|
||||
}
|
||||
if z.Owner != "" {
|
||||
parts = append(parts, fmt.Sprintf("owner:%s", cstrings.Truncate(z.Owner, 4)))
|
||||
}
|
||||
parts = append(parts, z.Type)
|
||||
if z.ID != "" {
|
||||
parts = append(parts, fmt.Sprintf("id:%s", cstrings.Truncate(z.ID, 4)))
|
||||
}
|
||||
if len(z.ACLGroupList) > 0 || len(z.ACLUserList) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("acl:%d", len(z.ACLUserList)+len(z.ACLGroupList)))
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
// ValidAction checks if the action is valid for the given object type.
|
||||
func (z Object) ValidAction(action policy.Action) error {
|
||||
perms, ok := policy.RBACPermissions[z.Type]
|
||||
|
||||
+134
-72
@@ -406,31 +406,84 @@ func (api *API) postUserWorkspaces(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx = r.Context()
|
||||
apiKey = httpmw.APIKey(r)
|
||||
auditor = api.Auditor.Load()
|
||||
user = httpmw.UserParam(r)
|
||||
)
|
||||
|
||||
var req codersdk.CreateWorkspaceRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
var owner workspaceOwner
|
||||
// This user fetch is an optimization path for the most common case of creating a
|
||||
// workspace for 'Me'.
|
||||
//
|
||||
// This is also required to allow `owners` to create workspaces for users
|
||||
// that are not in an organization.
|
||||
user, ok := httpmw.UserParamOptional(r)
|
||||
if ok {
|
||||
owner = workspaceOwner{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
AvatarURL: user.AvatarURL,
|
||||
}
|
||||
} else {
|
||||
// A workspace can still be created if the caller can read the organization
|
||||
// member. The organization is required, which can be sourced from the
|
||||
// template.
|
||||
//
|
||||
// TODO: This code gets called twice for each workspace build request.
|
||||
// This is inefficient and costs at most 2 extra RTTs to the DB.
|
||||
// This can be optimized. It exists as it is now for code simplicity.
|
||||
// The most common case is to create a workspace for 'Me'. Which does
|
||||
// not enter this code branch.
|
||||
template, ok := requestTemplate(ctx, rw, req, api.Database)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// We need to fetch the original user as a system user to fetch the
|
||||
// user_id. 'ExtractUserContext' handles all cases like usernames,
|
||||
// 'Me', etc.
|
||||
// nolint:gocritic // The user_id needs to be fetched. This handles all those cases.
|
||||
user, ok := httpmw.ExtractUserContext(dbauthz.AsSystemRestricted(ctx), api.Database, rw, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
organizationMember, err := database.ExpectOne(api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{
|
||||
OrganizationID: template.OrganizationID,
|
||||
UserID: user.ID,
|
||||
IncludeSystem: false,
|
||||
}))
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching organization member.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
owner = workspaceOwner{
|
||||
ID: organizationMember.OrganizationMember.UserID,
|
||||
Username: organizationMember.Username,
|
||||
AvatarURL: organizationMember.AvatarURL,
|
||||
}
|
||||
}
|
||||
|
||||
aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionCreate,
|
||||
AdditionalFields: audit.AdditionalFields{
|
||||
WorkspaceOwner: user.Username,
|
||||
WorkspaceOwner: owner.Username,
|
||||
},
|
||||
})
|
||||
|
||||
defer commitAudit()
|
||||
|
||||
var req codersdk.CreateWorkspaceRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
owner := workspaceOwner{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
AvatarURL: user.AvatarURL,
|
||||
}
|
||||
createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, rw, r)
|
||||
}
|
||||
|
||||
@@ -450,65 +503,8 @@ func createWorkspace(
|
||||
rw http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
// If we were given a `TemplateVersionID`, we need to determine the `TemplateID` from it.
|
||||
templateID := req.TemplateID
|
||||
if templateID == uuid.Nil {
|
||||
templateVersion, err := api.Database.GetTemplateVersionByID(ctx, req.TemplateVersionID)
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Template version %q doesn't exist.", templateID.String()),
|
||||
Validations: []codersdk.ValidationError{{
|
||||
Field: "template_version_id",
|
||||
Detail: "template not found",
|
||||
}},
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching template version.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if templateVersion.Archived {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Archived template versions cannot be used to make a workspace.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{
|
||||
Field: "template_version_id",
|
||||
Detail: "template version archived",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
templateID = templateVersion.TemplateID.UUID
|
||||
}
|
||||
|
||||
template, err := api.Database.GetTemplateByID(ctx, templateID)
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Template %q doesn't exist.", templateID.String()),
|
||||
Validations: []codersdk.ValidationError{{
|
||||
Field: "template_id",
|
||||
Detail: "template not found",
|
||||
}},
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching template.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if template.Deleted {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: fmt.Sprintf("Template %q has been deleted!", template.Name),
|
||||
})
|
||||
template, ok := requestTemplate(ctx, rw, req, api.Database)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -776,6 +772,72 @@ func createWorkspace(
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, w)
|
||||
}
|
||||
|
||||
func requestTemplate(ctx context.Context, rw http.ResponseWriter, req codersdk.CreateWorkspaceRequest, db database.Store) (database.Template, bool) {
|
||||
// If we were given a `TemplateVersionID`, we need to determine the `TemplateID` from it.
|
||||
templateID := req.TemplateID
|
||||
|
||||
if templateID == uuid.Nil {
|
||||
templateVersion, err := db.GetTemplateVersionByID(ctx, req.TemplateVersionID)
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Template version %q doesn't exist.", req.TemplateVersionID),
|
||||
Validations: []codersdk.ValidationError{{
|
||||
Field: "template_version_id",
|
||||
Detail: "template not found",
|
||||
}},
|
||||
})
|
||||
return database.Template{}, false
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching template version.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return database.Template{}, false
|
||||
}
|
||||
if templateVersion.Archived {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Archived template versions cannot be used to make a workspace.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{
|
||||
Field: "template_version_id",
|
||||
Detail: "template version archived",
|
||||
},
|
||||
},
|
||||
})
|
||||
return database.Template{}, false
|
||||
}
|
||||
|
||||
templateID = templateVersion.TemplateID.UUID
|
||||
}
|
||||
|
||||
template, err := db.GetTemplateByID(ctx, templateID)
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Template %q doesn't exist.", templateID),
|
||||
Validations: []codersdk.ValidationError{{
|
||||
Field: "template_id",
|
||||
Detail: "template not found",
|
||||
}},
|
||||
})
|
||||
return database.Template{}, false
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching template.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return database.Template{}, false
|
||||
}
|
||||
if template.Deleted {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: fmt.Sprintf("Template %q has been deleted!", template.Name),
|
||||
})
|
||||
return database.Template{}, false
|
||||
}
|
||||
return template, true
|
||||
}
|
||||
|
||||
func (api *API) notifyWorkspaceCreated(
|
||||
ctx context.Context,
|
||||
receiverID uuid.UUID,
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
agplschedule "github.com/coder/coder/v2/coderd/schedule"
|
||||
"github.com/coder/coder/v2/coderd/schedule/cron"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
@@ -245,7 +246,131 @@ func TestCreateWorkspace(t *testing.T) {
|
||||
func TestCreateUserWorkspace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a custom role that can create workspaces for another user.
|
||||
t.Run("ForAnotherUser", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner, first := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureCustomRoles: 1,
|
||||
codersdk.FeatureTemplateRBAC: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
//nolint:gocritic // using owner to setup roles
|
||||
r, err := owner.CreateOrganizationRole(ctx, codersdk.Role{
|
||||
Name: "creator",
|
||||
OrganizationID: first.OrganizationID.String(),
|
||||
DisplayName: "Creator",
|
||||
OrganizationPermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionCreate, codersdk.ActionWorkspaceStart, codersdk.ActionUpdate, codersdk.ActionRead},
|
||||
codersdk.ResourceOrganizationMember: {codersdk.ActionRead},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// use admin for setting up test
|
||||
admin, adminID := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
||||
// try the test action with this user & custom role
|
||||
creator, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.RoleMember(), rbac.RoleIdentifier{
|
||||
Name: r.Name,
|
||||
OrganizationID: first.OrganizationID,
|
||||
})
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, admin, first.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, admin, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, admin, first.OrganizationID, version.ID)
|
||||
|
||||
ctx = testutil.Context(t, testutil.WaitLong*1000) // Reset the context to avoid timeouts.
|
||||
|
||||
_, err = creator.CreateUserWorkspace(ctx, adminID.ID.String(), codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
Name: "workspace",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
// Asserting some authz calls when creating a workspace.
|
||||
t.Run("AuthzStory", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner, _, api, first := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureCustomRoles: 1,
|
||||
codersdk.FeatureTemplateRBAC: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong*2000)
|
||||
defer cancel()
|
||||
|
||||
//nolint:gocritic // using owner to setup roles
|
||||
creatorRole, err := owner.CreateOrganizationRole(ctx, codersdk.Role{
|
||||
Name: "creator",
|
||||
OrganizationID: first.OrganizationID.String(),
|
||||
OrganizationPermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionCreate, codersdk.ActionWorkspaceStart, codersdk.ActionUpdate, codersdk.ActionRead},
|
||||
codersdk.ResourceOrganizationMember: {codersdk.ActionRead},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, owner, first.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, owner, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, owner, first.OrganizationID, version.ID)
|
||||
_, userID := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID)
|
||||
creator, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.RoleIdentifier{
|
||||
Name: creatorRole.Name,
|
||||
OrganizationID: first.OrganizationID,
|
||||
})
|
||||
|
||||
// Create a workspace with the current api using an org admin.
|
||||
authz := coderdtest.AssertRBAC(t, api.AGPL, creator)
|
||||
authz.Reset() // Reset all previous checks done in setup.
|
||||
_, err = creator.CreateUserWorkspace(ctx, userID.ID.String(), codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
Name: "test-user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Assert all authz properties
|
||||
t.Run("OnlyOrganizationAuthzCalls", func(t *testing.T) {
|
||||
// Creating workspaces is an organization action. So organization
|
||||
// permissions should be sufficient to complete the action.
|
||||
for _, call := range authz.AllCalls() {
|
||||
if call.Action == policy.ActionRead &&
|
||||
call.Object.Equal(rbac.ResourceUser.WithOwner(userID.ID.String()).WithID(userID.ID)) {
|
||||
// User read checks are called. If they fail, ignore them.
|
||||
if call.Err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if call.Object.Type == rbac.ResourceDeploymentConfig.Type {
|
||||
continue // Ignore
|
||||
}
|
||||
|
||||
assert.Falsef(t, call.Object.OrgID == "",
|
||||
"call %q for object %q has no organization set. Site authz calls not expected here",
|
||||
call.Action, call.Object.String(),
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NoTemplateAccess", func(t *testing.T) {
|
||||
// NoTemplateAccess intentionally does not use provisioners. The template
|
||||
// version will be stuck in 'pending' forever.
|
||||
t.Parallel()
|
||||
|
||||
client, first := coderdenttest.New(t, &coderdenttest.Options{
|
||||
|
||||
Reference in New Issue
Block a user