fix: only return group member count for workspace acl (#26206) (#27882) · coder/coder@d70055a · GitHub
Skip to content

Commit d70055a

Browse files
fix: only return group member count for workspace acl (#26206) (#27882)
Backport of #26206 Original PR: #26206 — fix: only return group member count for workspace acl Merge commit: c7ddcce Requested by: @jdomeracki-coder Clean cherry-pick, no conflicts. --- _Opened by Coder Agents on behalf of @jdomeracki-coder._ Co-authored-by: Jon Ayers <jon@coder.com>
1 parent 5c059ba commit d70055a

5 files changed

Lines changed: 150 additions & 35 deletions

File tree

cli/sharing.go

Lines changed: 8 additions & 7 deletions

cli/sharing_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,48 @@ func TestSharingStatus(t *testing.T) {
205205
}
206206
assert.True(t, found, "expected to find username %s with role %s in the output: %s", toShareWithUser.Username, codersdk.WorkspaceRoleUse, out.String())
207207
})
208+
209+
t.Run("ListSharedGroups", func(t *testing.T) {
210+
t.Parallel()
211+
212+
var (
213+
client, db = coderdtest.NewWithDatabase(t, nil)
214+
orgOwner = coderdtest.CreateFirstUser(t, client)
215+
workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID))
216+
workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
217+
OwnerID: workspaceOwner.ID,
218+
OrganizationID: orgOwner.OrganizationID,
219+
}).Do().Workspace
220+
ctx = testutil.Context(t, testutil.WaitMedium)
221+
)
222+
223+
// The Everyone group always exists for an organization and shares the
224+
// organization's ID. The workspace ACL endpoint no longer returns the
225+
// group's member roster, so the CLI must still list the group itself.
226+
err := client.UpdateWorkspaceACL(ctx, workspace.ID, codersdk.UpdateWorkspaceACL{
227+
GroupRoles: map[string]codersdk.WorkspaceRole{
228+
orgOwner.OrganizationID.String(): codersdk.WorkspaceRoleUse,
229+
},
230+
})
231+
require.NoError(t, err)
232+
233+
inv, root := clitest.New(t, "sharing", "status", workspace.Name)
234+
clitest.SetupConfig(t, workspaceOwnerClient, root)
235+
236+
out := new(bytes.Buffer)
237+
inv.Stdout = out
238+
err = inv.WithContext(ctx).Run()
239+
require.NoError(t, err)
240+
241+
found := false
242+
for _, line := range strings.Split(out.String(), "\n") {
243+
if strings.Contains(line, database.EveryoneGroup) && strings.Contains(line, string(codersdk.WorkspaceRoleUse)) {
244+
found = true
245+
break
246+
}
247+
}
248+
assert.True(t, found, "expected to find group %s with role %s in the output: %s", database.EveryoneGroup, codersdk.WorkspaceRoleUse, out.String())
249+
})
208250
}
209251

210252
func TestSharingRemove(t *testing.T) {

coderd/workspaces.go

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2372,13 +2372,13 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) {
23722372
return
23732373
}
23742374

2375-
// This is largely based on the template ACL implementation, and is far from
2376-
// ideal. Usually, when we use the System context it's because we need to
2377-
// run some query that won't actually be exposed to the user. That is not
2378-
// the case here. This data goes directly to an unauthorized user. We are
2379-
// just straight up breaking security promises.
2380-
//
2381-
// TODO: This needs to be fixed before GA. Currently in beta.
2375+
// Callers are authorized to read this workspace, not necessarily the
2376+
// users and groups on its ACL. We deliberately use the System context to
2377+
// look up that data, but only return minimal identity information that is
2378+
// safe to expose to anyone who can read the ACL: MinimalUser for ACL users
2379+
// (no email or other PII) and group identity plus a member count for ACL
2380+
// groups (no member roster). This mirrors the chat ACL and template
2381+
// available-ACL endpoints.
23822382

23832383
// Fetch all of the users and their organization memberships
23842384
userIDs := make([]uuid.UUID, 0, len(workspaceACL.Users))
@@ -2390,7 +2390,8 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) {
23902390
}
23912391
userIDs = append(userIDs, id)
23922392
}
2393-
// For context see https://github.com/coder/coder/pull/19375
2393+
// ACL users are returned as MinimalUser, which contains no PII, so it is
2394+
// safe to fetch them under the System context.
23942395
// nolint:gocritic
23952396
dbUsers, err := api.Database.GetUsersByIDs(dbauthz.AsSystemRestricted(ctx), userIDs)
23962397
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
@@ -2422,7 +2423,8 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) {
24222423
// before making the DB call.
24232424
dbGroups := make([]database.GetGroupsRow, 0)
24242425
if len(groupIDs) > 0 {
2425-
// For context see https://github.com/coder/coder/pull/19375
2426+
// Group identity must be visible to anyone who can read the ACL so
2427+
// that owners and shared users can see and manage entries.
24262428
// nolint:gocritic
24272429
dbGroups, err = api.Database.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: groupIDs})
24282430
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
@@ -2431,26 +2433,30 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) {
24312433
}
24322434
}
24332435

2436+
// Fetch member counts for all groups in a single query to avoid an N+1
2437+
// lookup. We intentionally do not populate the per-group member rosters:
2438+
// callers authorized to read the ACL are not necessarily authorized to
2439+
// read group membership, and the roster includes member PII. Only the
2440+
// total member count is returned (see Group.TotalMemberCount).
2441+
// nolint:gocritic
2442+
countRows, err := api.Database.GetGroupMembersCountByGroupIDs(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDsParams{
2443+
GroupIds: groupIDs,
2444+
IncludeSystem: false,
2445+
})
2446+
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
2447+
httpapi.InternalServerError(rw, err)
2448+
return
2449+
}
2450+
countByGroup := make(map[uuid.UUID]int64, len(countRows))
2451+
for _, row := range countRows {
2452+
countByGroup[row.GroupID] = row.MemberCount
2453+
}
2454+
24342455
groups := make([]codersdk.WorkspaceGroup, 0, len(dbGroups))
24352456
for _, it := range dbGroups {
2436-
var members []database.GroupMember
2437-
// For context see https://github.com/coder/coder/pull/19375
2438-
// nolint:gocritic
2439-
members, err = api.Database.GetGroupMembersByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersByGroupIDParams{
2440-
GroupID: it.Group.ID,
2441-
IncludeSystem: false,
2442-
})
2443-
if err != nil {
2444-
httpapi.InternalServerError(rw, err)
2445-
return
2446-
}
24472457
groups = append(groups, codersdk.WorkspaceGroup{
2448-
Group: db2sdk.Group(database.GetGroupsRow{
2449-
Group: it.Group,
2450-
OrganizationName: it.OrganizationName,
2451-
OrganizationDisplayName: it.OrganizationDisplayName,
2452-
}, members, len(members)),
2453-
Role: convertToWorkspaceRole(workspaceACL.Groups[it.Group.ID.String()].Permissions),
2458+
Group: db2sdk.Group(it, nil, int(countByGroup[it.Group.ID])),
2459+
Role: convertToWorkspaceRole(workspaceACL.Groups[it.Group.ID.String()].Permissions),
24542460
})
24552461
}
24562462

enterprise/cli/sharing_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,14 +216,17 @@ func TestSharingStatus(t *testing.T) {
216216
err = inv.WithContext(ctx).Run()
217217
require.NoError(t, err)
218218

219+
// The ACL endpoint omits group member rosters to avoid leaking member
220+
// PII, so the output lists the group itself rather than its members.
219221
found := false
220222
for _, line := range strings.Split(out.String(), "\n") {
221-
if strings.Contains(line, orgMember.Username) && strings.Contains(line, string(codersdk.WorkspaceRoleUse)) && strings.Contains(line, group.Name) {
223+
if strings.Contains(line, group.Name) && strings.Contains(line, string(codersdk.WorkspaceRoleUse)) {
222224
found = true
223225
break
224226
}
225227
}
226-
assert.True(t, found, "expected to find username %s with role %s in the output: %s", orgMember.Username, codersdk.WorkspaceRoleUse, out.String())
228+
assert.True(t, found, "expected to find group %s with role %s in the output: %s", group.Name, codersdk.WorkspaceRoleUse, out.String())
229+
assert.NotContains(t, out.String(), orgMember.Username, "group member roster must not be exposed in sharing status output")
227230
})
228231
}
229232

enterprise/coderd/workspaces_test.go

Lines changed: 63 additions & 0 deletions

0 commit comments

Comments
 (0)