diff --git a/coderd/coderd.go b/coderd/coderd.go index c9a0f741afd1f..5732dda77a675 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1147,64 +1147,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.Route("/{keyid}", func(r chi.Router) { - r.Get("/", api.apiKeyByID) - r.Delete("/", api.deleteAPIKey) + 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("/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) }) }) }) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index af52f7fc70f53..279405c4e6a21 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -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) { diff --git a/coderd/httpapi/noop.go b/coderd/httpapi/noop.go new file mode 100644 index 0000000000000..52a0f5dd4d8a4 --- /dev/null +++ b/coderd/httpapi/noop.go @@ -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) {} diff --git a/coderd/httpmw/organizationparam.go b/coderd/httpmw/organizationparam.go index 18938ec1e792d..782a0d37e1985 100644 --- a/coderd/httpmw/organizationparam.go +++ b/coderd/httpmw/organizationparam.go @@ -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 } diff --git a/coderd/httpmw/userparam.go b/coderd/httpmw/userparam.go index 03bff9bbb5596..2fbcc458489f9 100644 --- a/coderd/httpmw/userparam.go +++ b/coderd/httpmw/userparam.go @@ -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" { diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 4f42de94a4c52..9beef03dd8f9a 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -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] diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 84862d9c400c9..8b91abc07206b 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -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, diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index eedd6f1bcfa1c..72859c5460fa7 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -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{
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: