mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
chore: add /groups endpoint to filter by organization
and/or member
(#14260)
* chore: merge get groups sql queries into 1 * Add endpoint for fetching groups with filters * remove 2 ways to customizing a fake authorizer
This commit is contained in:
@ -343,15 +343,20 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
||||
r.Get("/", api.templateACL)
|
||||
r.Patch("/", api.patchTemplateACL)
|
||||
})
|
||||
r.Route("/groups/{group}", func(r chi.Router) {
|
||||
r.Route("/groups", func(r chi.Router) {
|
||||
r.Use(
|
||||
api.templateRBACEnabledMW,
|
||||
apiKeyMiddleware,
|
||||
httpmw.ExtractGroupParam(api.Database),
|
||||
)
|
||||
r.Get("/", api.group)
|
||||
r.Patch("/", api.patchGroup)
|
||||
r.Delete("/", api.deleteGroup)
|
||||
r.Get("/", api.groups)
|
||||
r.Route("/{group}", func(r chi.Router) {
|
||||
r.Use(
|
||||
httpmw.ExtractGroupParam(api.Database),
|
||||
)
|
||||
r.Get("/", api.group)
|
||||
r.Patch("/", api.patchGroup)
|
||||
r.Delete("/", api.deleteGroup)
|
||||
})
|
||||
})
|
||||
r.Route("/workspace-quota", func(r chi.Router) {
|
||||
r.Use(
|
||||
|
@ -9,13 +9,11 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@ -394,28 +392,65 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Success 200 {array} codersdk.Group
|
||||
// @Router /organizations/{organization}/groups [get]
|
||||
func (api *API) groupsByOrganization(rw http.ResponseWriter, r *http.Request) {
|
||||
org := httpmw.OrganizationParam(r)
|
||||
|
||||
values := r.URL.Query()
|
||||
values.Set("organization", org.ID.String())
|
||||
r.URL.RawQuery = values.Encode()
|
||||
|
||||
api.groups(rw, r)
|
||||
}
|
||||
|
||||
// @Summary Get groups
|
||||
// @ID get-groups
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param organization query string true "Organization ID or name"
|
||||
// @Param has_member query string true "User ID or name"
|
||||
// @Success 200 {array} codersdk.Group
|
||||
// @Router /groups [get]
|
||||
func (api *API) groups(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
org = httpmw.OrganizationParam(r)
|
||||
)
|
||||
ctx := r.Context()
|
||||
|
||||
groups, err := api.Database.GetGroupsByOrganizationID(ctx, org.ID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
var filter database.GetGroupsParams
|
||||
parser := httpapi.NewQueryParamParser()
|
||||
// Organization selector can be an org ID or name
|
||||
filter.OrganizationID = parser.UUIDorName(r.URL.Query(), uuid.Nil, "organization", func(orgName string) (uuid.UUID, error) {
|
||||
org, err := api.Database.GetOrganizationByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.Errorf("organization %q not found", orgName)
|
||||
}
|
||||
return org.ID, nil
|
||||
})
|
||||
|
||||
// has_member selector can be a user ID or username
|
||||
filter.HasMemberID = parser.UUIDorName(r.URL.Query(), uuid.Nil, "has_member", func(username string) (uuid.UUID, error) {
|
||||
user, err := api.Database.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
|
||||
Username: username,
|
||||
Email: "",
|
||||
})
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.Errorf("user %q not found", username)
|
||||
}
|
||||
return user.ID, nil
|
||||
})
|
||||
parser.ErrorExcessParams(r.URL.Query())
|
||||
if len(parser.Errors) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Query parameters have invalid values.",
|
||||
Validations: parser.Errors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Filter groups based on rbac permissions
|
||||
groups, err = coderd.AuthorizeFilter(api.AGPL.HTTPAuth, r, policy.ActionRead, groups)
|
||||
groups, err := api.Database.GetGroups(ctx, filter)
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching groups.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
@ -11,6 +12,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@ -568,7 +570,19 @@ func TestPatchGroup(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func sortGroupMembers(group *codersdk.Group) {
|
||||
func normalizeAllGroups(groups []codersdk.Group) {
|
||||
for i := range groups {
|
||||
normalizeGroupMembers(&groups[i])
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeGroupMembers removes comparison noise from the group members.
|
||||
func normalizeGroupMembers(group *codersdk.Group) {
|
||||
for i := range group.Members {
|
||||
group.Members[i].LastSeenAt = time.Time{}
|
||||
group.Members[i].CreatedAt = time.Time{}
|
||||
group.Members[i].UpdatedAt = time.Time{}
|
||||
}
|
||||
sort.Slice(group.Members, func(i, j int) bool {
|
||||
return group.Members[i].ID.String() < group.Members[j].ID.String()
|
||||
})
|
||||
@ -645,8 +659,8 @@ func TestGroup(t *testing.T) {
|
||||
|
||||
ggroup, err := userAdminClient.Group(ctx, group.ID)
|
||||
require.NoError(t, err)
|
||||
sortGroupMembers(&group)
|
||||
sortGroupMembers(&ggroup)
|
||||
normalizeGroupMembers(&group)
|
||||
normalizeGroupMembers(&ggroup)
|
||||
|
||||
require.Equal(t, group, ggroup)
|
||||
})
|
||||
@ -793,6 +807,8 @@ func TestGroup(t *testing.T) {
|
||||
func TestGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 5 users
|
||||
// 2 custom groups + original org group
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@ -805,7 +821,7 @@ func TestGroups(t *testing.T) {
|
||||
_, user2 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
_, user3 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
_, user4 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
_, user5 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
user5Client, user5 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
group1, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
|
||||
@ -822,26 +838,64 @@ func TestGroups(t *testing.T) {
|
||||
AddUsers: []string{user2.ID.String(), user3.ID.String()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
normalizeGroupMembers(&group1)
|
||||
|
||||
group2, err = userAdminClient.PatchGroup(ctx, group2.ID, codersdk.PatchGroupRequest{
|
||||
AddUsers: []string{user4.ID.String(), user5.ID.String()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
normalizeGroupMembers(&group2)
|
||||
|
||||
groups, err := userAdminClient.GroupsByOrganization(ctx, user.OrganizationID)
|
||||
// Fetch everyone group for comparison
|
||||
everyoneGroup, err := userAdminClient.Group(ctx, user.OrganizationID)
|
||||
require.NoError(t, err)
|
||||
normalizeGroupMembers(&everyoneGroup)
|
||||
|
||||
// sort group members so we can compare them
|
||||
allGroups := append([]codersdk.Group{}, groups...)
|
||||
allGroups = append(allGroups, group1, group2)
|
||||
for i := range allGroups {
|
||||
sortGroupMembers(&allGroups[i])
|
||||
}
|
||||
groups, err := userAdminClient.Groups(ctx, codersdk.GroupArguments{
|
||||
Organization: user.OrganizationID.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
normalizeAllGroups(groups)
|
||||
|
||||
// 'Everyone' group + 2 custom groups.
|
||||
require.Len(t, groups, 3)
|
||||
require.Contains(t, groups, group1)
|
||||
require.Contains(t, groups, group2)
|
||||
require.ElementsMatch(t, []codersdk.Group{
|
||||
everyoneGroup,
|
||||
group1,
|
||||
group2,
|
||||
}, groups)
|
||||
|
||||
// Filter by user
|
||||
user5Groups, err := userAdminClient.Groups(ctx, codersdk.GroupArguments{
|
||||
HasMember: user5.Username,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
normalizeAllGroups(user5Groups)
|
||||
// Everyone group and group 2
|
||||
require.ElementsMatch(t, []codersdk.Group{
|
||||
everyoneGroup,
|
||||
group2,
|
||||
}, user5Groups)
|
||||
|
||||
// Query from the user's perspective
|
||||
user5View, err := user5Client.Groups(ctx, codersdk.GroupArguments{})
|
||||
require.NoError(t, err)
|
||||
normalizeAllGroups(user5Groups)
|
||||
|
||||
// Everyone group and group 2
|
||||
require.Len(t, user5View, 2)
|
||||
user5ViewIDs := db2sdk.List(user5View, func(g codersdk.Group) uuid.UUID {
|
||||
return g.ID
|
||||
})
|
||||
|
||||
require.ElementsMatch(t, []uuid.UUID{
|
||||
everyoneGroup.ID,
|
||||
group2.ID,
|
||||
}, user5ViewIDs)
|
||||
for _, g := range user5View {
|
||||
// Only expect the 1 member, themselves
|
||||
require.Len(t, g.Members, 1)
|
||||
require.Equal(t, user5.ReducedUser.ID, g.Members[0].MinimalUser.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -50,7 +50,9 @@ func (api *API) templateAvailablePermissions(rw http.ResponseWriter, r *http.Req
|
||||
|
||||
// Perm check is the template update check.
|
||||
// nolint:gocritic
|
||||
groups, err := api.Database.GetGroupsByOrganizationID(dbauthz.AsSystemRestricted(ctx), template.OrganizationID)
|
||||
groups, err := api.Database.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{
|
||||
OrganizationID: template.OrganizationID,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
|
Reference in New Issue
Block a user