Skip to content

Commit d5360a6

Browse files
authored
chore: fetch workspaces by username with organization permissions (#17707)
Closes #17691 `ExtractOrganizationMembersParam` will allow fetching a user with only organization permissions. If the user belongs to 0 orgs, then the user "does not exist" from an org perspective. But if you are a site-wide admin, then the user does exist.
1 parent d93a9cf commit d5360a6

File tree

6 files changed

+185
-74
lines changed

6 files changed

+185
-74
lines changed

coderd/coderd.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,26 +1189,32 @@ func New(options *Options) *API {
11891189
})
11901190
r.Route("/{user}", func(r chi.Router) {
11911191
r.Group(func(r chi.Router) {
1192-
r.Use(httpmw.ExtractUserParamOptional(options.Database))
1192+
r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize))
11931193
// Creating workspaces does not require permissions on the user, only the
11941194
// organization member. This endpoint should match the authz story of
11951195
// postWorkspacesByOrganization
11961196
r.Post("/workspaces", api.postUserWorkspaces)
1197+
r.Route("/workspace/{workspacename}", func(r chi.Router) {
1198+
r.Get("/", api.workspaceByOwnerAndName)
1199+
r.Get("/builds/{buildnumber}", api.workspaceBuildByBuildNumber)
1200+
})
1201+
})
1202+
1203+
r.Group(func(r chi.Router) {
1204+
r.Use(httpmw.ExtractUserParam(options.Database))
11971205

11981206
// Similarly to creating a workspace, evaluating parameters for a
11991207
// new workspace should also match the authz story of
12001208
// postWorkspacesByOrganization
1209+
// TODO: Do not require site wide read user permission. Make this work
1210+
// with org member permissions.
12011211
r.Route("/templateversions/{templateversion}", func(r chi.Router) {
12021212
r.Use(
12031213
httpmw.ExtractTemplateVersionParam(options.Database),
12041214
httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentDynamicParameters),
12051215
)
12061216
r.Get("/parameters", api.templateVersionDynamicParameters)
12071217
})
1208-
})
1209-
1210-
r.Group(func(r chi.Router) {
1211-
r.Use(httpmw.ExtractUserParam(options.Database))
12121218

12131219
r.Post("/convert-login", api.postConvertLoginType)
12141220
r.Delete("/", api.deleteUser)
@@ -1250,10 +1256,7 @@ func New(options *Options) *API {
12501256
r.Get("/", api.organizationsByUser)
12511257
r.Get("/{organizationname}", api.organizationByUserAndName)
12521258
})
1253-
r.Route("/workspace/{workspacename}", func(r chi.Router) {
1254-
r.Get("/", api.workspaceByOwnerAndName)
1255-
r.Get("/builds/{buildnumber}", api.workspaceBuildByBuildNumber)
1256-
})
1259+
12571260
r.Get("/gitsshkey", api.gitSSHKey)
12581261
r.Put("/gitsshkey", api.regenerateGitSSHKey)
12591262
r.Route("/notifications", func(r chi.Router) {

coderd/httpmw/organizationparam.go

Lines changed: 128 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ import (
1111
"github.com/coder/coder/v2/coderd/database"
1212
"github.com/coder/coder/v2/coderd/database/dbauthz"
1313
"github.com/coder/coder/v2/coderd/httpapi"
14+
"github.com/coder/coder/v2/coderd/rbac"
15+
"github.com/coder/coder/v2/coderd/rbac/policy"
1416
"github.com/coder/coder/v2/codersdk"
1517
)
1618

1719
type (
18-
organizationParamContextKey struct{}
19-
organizationMemberParamContextKey struct{}
20+
organizationParamContextKey struct{}
21+
organizationMemberParamContextKey struct{}
22+
organizationMembersParamContextKey struct{}
2023
)
2124

2225
// OrganizationParam returns the organization from the ExtractOrganizationParam handler.
@@ -38,6 +41,14 @@ func OrganizationMemberParam(r *http.Request) OrganizationMember {
3841
return organizationMember
3942
}
4043

44+
func OrganizationMembersParam(r *http.Request) OrganizationMembers {
45+
organizationMembers, ok := r.Context().Value(organizationMembersParamContextKey{}).(OrganizationMembers)
46+
if !ok {
47+
panic("developer error: organization members param middleware not provided")
48+
}
49+
return organizationMembers
50+
}
51+
4152
// ExtractOrganizationParam grabs an organization from the "organization" URL parameter.
4253
// This middleware requires the API key middleware higher in the call stack for authentication.
4354
func ExtractOrganizationParam(db database.Store) func(http.Handler) http.Handler {
@@ -111,35 +122,23 @@ func ExtractOrganizationMemberParam(db database.Store) func(http.Handler) http.H
111122
return func(next http.Handler) http.Handler {
112123
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
113124
ctx := r.Context()
114-
// We need to resolve the `{user}` URL parameter so that we can get the userID and
115-
// username. We do this as SystemRestricted since the caller might have permission
116-
// to access the OrganizationMember object, but *not* the User object. So, it is
117-
// very important that we do not add the User object to the request context or otherwise
118-
// leak it to the API handler.
119-
// nolint:gocritic
120-
user, ok := ExtractUserContext(dbauthz.AsSystemRestricted(ctx), db, rw, r)
121-
if !ok {
122-
return
123-
}
124125
organization := OrganizationParam(r)
125-
126-
organizationMember, err := database.ExpectOne(db.OrganizationMembers(ctx, database.OrganizationMembersParams{
127-
OrganizationID: organization.ID,
128-
UserID: user.ID,
129-
IncludeSystem: false,
130-
}))
131-
if httpapi.Is404Error(err) {
132-
httpapi.ResourceNotFound(rw)
126+
_, members, done := ExtractOrganizationMember(ctx, nil, rw, r, db, organization.ID)
127+
if done {
133128
return
134129
}
135-
if err != nil {
130+
131+
if len(members) != 1 {
136132
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
137133
Message: "Internal error fetching organization member.",
138-
Detail: err.Error(),
134+
// This is a developer error and should never happen.
135+
Detail: fmt.Sprintf("Expected exactly one organization member, but got %d.", len(members)),
139136
})
140137
return
141138
}
142139

140+
organizationMember := members[0]
141+
143142
ctx = context.WithValue(ctx, organizationMemberParamContextKey{}, OrganizationMember{
144143
OrganizationMember: organizationMember.OrganizationMember,
145144
// Here we're making two exceptions to the rule about not leaking data about the user
@@ -151,8 +150,113 @@ func ExtractOrganizationMemberParam(db database.Store) func(http.Handler) http.H
151150
// API handlers need this information for audit logging and returning the owner's
152151
// username in response to creating a workspace. Additionally, the frontend consumes
153152
// the Avatar URL and this allows the FE to avoid an extra request.
154-
Username: user.Username,
155-
AvatarURL: user.AvatarURL,
153+
Username: organizationMember.Username,
154+
AvatarURL: organizationMember.AvatarURL,
155+
})
156+
157+
next.ServeHTTP(rw, r.WithContext(ctx))
158+
})
159+
}
160+
}
161+
162+
// ExtractOrganizationMember extracts all user memberships from the "user" URL
163+
// parameter. If orgID is uuid.Nil, then it will return all memberships for the
164+
// user, otherwise it will only return memberships to the org.
165+
//
166+
// If `user` is returned, that means the caller can use the data. This is returned because
167+
// it is possible to have a user with 0 organizations. So the user != nil, with 0 memberships.
168+
func ExtractOrganizationMember(ctx context.Context, auth func(r *http.Request, action policy.Action, object rbac.Objecter) bool, rw http.ResponseWriter, r *http.Request, db database.Store, orgID uuid.UUID) (*database.User, []database.OrganizationMembersRow, bool) {
169+
// We need to resolve the `{user}` URL parameter so that we can get the userID and
170+
// username. We do this as SystemRestricted since the caller might have permission
171+
// to access the OrganizationMember object, but *not* the User object. So, it is
172+
// very important that we do not add the User object to the request context or otherwise
173+
// leak it to the API handler.
174+
// nolint:gocritic
175+
user, ok := ExtractUserContext(dbauthz.AsSystemRestricted(ctx), db, rw, r)
176+
if !ok {
177+
return nil, nil, true
178+
}
179+
180+
organizationMembers, err := db.OrganizationMembers(ctx, database.OrganizationMembersParams{
181+
OrganizationID: orgID,
182+
UserID: user.ID,
183+
IncludeSystem: false,
184+
})
185+
if httpapi.Is404Error(err) {
186+
httpapi.ResourceNotFound(rw)
187+
return nil, nil, true
188+
}
189+
if err != nil {
190+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
191+
Message: "Internal error fetching organization member.",
192+
Detail: err.Error(),
193+
})
194+
return nil, nil, true
195+
}
196+
197+
// Only return the user data if the caller can read the user object.
198+
if auth != nil && auth(r, policy.ActionRead, user) {
199+
return &user, organizationMembers, false
200+
}
201+
202+
// If the user cannot be read and 0 memberships exist, throw a 404 to not
203+
// leak the user existence.
204+
if len(organizationMembers) == 0 {
205+
httpapi.ResourceNotFound(rw)
206+
return nil, nil, true
207+
}
208+
209+
return nil, organizationMembers, false
210+
}
211+
212+
type OrganizationMembers struct {
213+
// User is `nil` if the caller is not allowed access to the site wide
214+
// user object.
215+
User *database.User
216+
// Memberships can only be length 0 if `user != nil`. If `user == nil`, then
217+
// memberships will be at least length 1.
218+
Memberships []OrganizationMember
219+
}
220+
221+
func (om OrganizationMembers) UserID() uuid.UUID {
222+
if om.User != nil {
223+
return om.User.ID
224+
}
225+
226+
if len(om.Memberships) > 0 {
227+
return om.Memberships[0].UserID
228+
}
229+
return uuid.Nil
230+
}
231+
232+
// ExtractOrganizationMembersParam grabs all user organization memberships.
233+
// Only requires the "user" URL parameter.
234+
//
235+
// Use this if you want to grab as much information for a user as you can.
236+
// From an organization context, site wide user information might not available.
237+
func ExtractOrganizationMembersParam(db database.Store, auth func(r *http.Request, action policy.Action, object rbac.Objecter) bool) func(http.Handler) http.Handler {
238+
return func(next http.Handler) http.Handler {
239+
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
240+
ctx := r.Context()
241+
242+
// Fetch all memberships
243+
user, members, done := ExtractOrganizationMember(ctx, auth, rw, r, db, uuid.Nil)
244+
if done {
245+
return
246+
}
247+
248+
orgMembers := make([]OrganizationMember, 0, len(members))
249+
for _, organizationMember := range members {
250+
orgMembers = append(orgMembers, OrganizationMember{
251+
OrganizationMember: organizationMember.OrganizationMember,
252+
Username: organizationMember.Username,
253+
AvatarURL: organizationMember.AvatarURL,
254+
})
255+
}
256+
257+
ctx = context.WithValue(ctx, organizationMembersParamContextKey{}, OrganizationMembers{
258+
User: user,
259+
Memberships: orgMembers,
156260
})
157261
next.ServeHTTP(rw, r.WithContext(ctx))
158262
})

coderd/httpmw/organizationparam_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import (
1616
"github.com/coder/coder/v2/coderd/database/dbmem"
1717
"github.com/coder/coder/v2/coderd/database/dbtime"
1818
"github.com/coder/coder/v2/coderd/httpmw"
19+
"github.com/coder/coder/v2/coderd/rbac"
20+
"github.com/coder/coder/v2/coderd/rbac/policy"
1921
"github.com/coder/coder/v2/codersdk"
2022
"github.com/coder/coder/v2/testutil"
2123
)
@@ -167,6 +169,10 @@ func TestOrganizationParam(t *testing.T) {
167169
httpmw.ExtractOrganizationParam(db),
168170
httpmw.ExtractUserParam(db),
169171
httpmw.ExtractOrganizationMemberParam(db),
172+
httpmw.ExtractOrganizationMembersParam(db, func(r *http.Request, _ policy.Action, _ rbac.Objecter) bool {
173+
// Assume the caller cannot read the member
174+
return false
175+
}),
170176
)
171177
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
172178
org := httpmw.OrganizationParam(r)
@@ -190,6 +196,11 @@ func TestOrganizationParam(t *testing.T) {
190196
assert.NotEmpty(t, orgMem.OrganizationMember.UpdatedAt)
191197
assert.NotEmpty(t, orgMem.OrganizationMember.UserID)
192198
assert.NotEmpty(t, orgMem.OrganizationMember.Roles)
199+
200+
orgMems := httpmw.OrganizationMembersParam(r)
201+
assert.NotZero(t, orgMems)
202+
assert.Equal(t, orgMem.UserID, orgMems.Memberships[0].UserID)
203+
assert.Nil(t, orgMems.User, "user data should not be available, hard coded false authorize")
193204
})
194205

195206
// Try by ID

coderd/workspacebuilds.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
232232
// @Router /users/{user}/workspace/{workspacename}/builds/{buildnumber} [get]
233233
func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Request) {
234234
ctx := r.Context()
235-
owner := httpmw.UserParam(r)
235+
mems := httpmw.OrganizationMembersParam(r)
236236
workspaceName := chi.URLParam(r, "workspacename")
237237
buildNumber, err := strconv.ParseInt(chi.URLParam(r, "buildnumber"), 10, 32)
238238
if err != nil {
@@ -244,7 +244,7 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ
244244
}
245245

246246
workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
247-
OwnerID: owner.ID,
247+
OwnerID: mems.UserID(),
248248
Name: workspaceName,
249249
})
250250
if httpapi.Is404Error(err) {

coderd/workspaces.go

Lines changed: 25 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,8 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
253253
// @Router /users/{user}/workspace/{workspacename} [get]
254254
func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) {
255255
ctx := r.Context()
256-
owner := httpmw.UserParam(r)
256+
257+
mems := httpmw.OrganizationMembersParam(r)
257258
workspaceName := chi.URLParam(r, "workspacename")
258259
apiKey := httpmw.APIKey(r)
259260

@@ -273,12 +274,12 @@ func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request)
273274
}
274275

275276
workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
276-
OwnerID: owner.ID,
277+
OwnerID: mems.UserID(),
277278
Name: workspaceName,
278279
})
279280
if includeDeleted && errors.Is(err, sql.ErrNoRows) {
280281
workspace, err = api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
281-
OwnerID: owner.ID,
282+
OwnerID: mems.UserID(),
282283
Name: workspaceName,
283284
Deleted: includeDeleted,
284285
})
@@ -408,6 +409,7 @@ func (api *API) postUserWorkspaces(rw http.ResponseWriter, r *http.Request) {
408409
ctx = r.Context()
409410
apiKey = httpmw.APIKey(r)
410411
auditor = api.Auditor.Load()
412+
mems = httpmw.OrganizationMembersParam(r)
411413
)
412414

413415
var req codersdk.CreateWorkspaceRequest
@@ -416,17 +418,16 @@ func (api *API) postUserWorkspaces(rw http.ResponseWriter, r *http.Request) {
416418
}
417419

418420
var owner workspaceOwner
419-
// This user fetch is an optimization path for the most common case of creating a
420-
// workspace for 'Me'.
421-
//
422-
// This is also required to allow `owners` to create workspaces for users
423-
// that are not in an organization.
424-
user, ok := httpmw.UserParamOptional(r)
425-
if ok {
421+
if mems.User != nil {
422+
// This user fetch is an optimization path for the most common case of creating a
423+
// workspace for 'Me'.
424+
//
425+
// This is also required to allow `owners` to create workspaces for users
426+
// that are not in an organization.
426427
owner = workspaceOwner{
427-
ID: user.ID,
428-
Username: user.Username,
429-
AvatarURL: user.AvatarURL,
428+
ID: mems.User.ID,
429+
Username: mems.User.Username,
430+
AvatarURL: mems.User.AvatarURL,
430431
}
431432
} else {
432433
// A workspace can still be created if the caller can read the organization
@@ -443,35 +444,21 @@ func (api *API) postUserWorkspaces(rw http.ResponseWriter, r *http.Request) {
443444
return
444445
}
445446

446-
// We need to fetch the original user as a system user to fetch the
447-
// user_id. 'ExtractUserContext' handles all cases like usernames,
448-
// 'Me', etc.
449-
// nolint:gocritic // The user_id needs to be fetched. This handles all those cases.
450-
user, ok := httpmw.ExtractUserContext(dbauthz.AsSystemRestricted(ctx), api.Database, rw, r)
451-
if !ok {
452-
return
453-
}
454-
455-
organizationMember, err := database.ExpectOne(api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{
456-
OrganizationID: template.OrganizationID,
457-
UserID: user.ID,
458-
IncludeSystem: false,
459-
}))
460-
if httpapi.Is404Error(err) {
447+
// If the caller can find the organization membership in the same org
448+
// as the template, then they can continue.
449+
orgIndex := slices.IndexFunc(mems.Memberships, func(mem httpmw.OrganizationMember) bool {
450+
return mem.OrganizationID == template.OrganizationID
451+
})
452+
if orgIndex == -1 {
461453
httpapi.ResourceNotFound(rw)
462454
return
463455
}
464-
if err != nil {
465-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
466-
Message: "Internal error fetching organization member.",
467-
Detail: err.Error(),
468-
})
469-
return
470-
}
456+
457+
member := mems.Memberships[orgIndex]
471458
owner = workspaceOwner{
472-
ID: organizationMember.OrganizationMember.UserID,
473-
Username: organizationMember.Username,
474-
AvatarURL: organizationMember.AvatarURL,
459+
ID: member.UserID,
460+
Username: member.Username,
461+
AvatarURL: member.AvatarURL,
475462
}
476463
}
477464

0 commit comments

Comments
 (0)