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:
Steven Masley
2025-04-09 14:35:43 -05:00
committed by GitHub
parent a03a54dd14
commit 0b58798a1a
8 changed files with 392 additions and 135 deletions
+62 -52
View File
@@ -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)
})
})
})
+12 -6
View File
@@ -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) {
+10
View File
@@ -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) {}
+1 -1
View File
@@ -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
}
+25 -4
View File
@@ -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" {
+23
View File
@@ -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
View File
@@ -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,
+125
View File
@@ -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{