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:
Steven Masley
2024-08-15 13:40:15 -05:00
committed by GitHub
parent 83ccdaa755
commit 7b09d98238
24 changed files with 539 additions and 289 deletions

View File

@ -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(

View File

@ -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
}

View File

@ -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)
}
})
}

View File

@ -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