diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index b0cfedb119..dae0755cb5 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1691,6 +1691,7 @@ func (q *querier) DeleteOrganizationMember(ctx context.Context, arg database.Del OrganizationID: arg.OrganizationID, UserID: arg.UserID, IncludeSystem: false, + GithubUserID: 0, })) if err != nil { return database.OrganizationMember{}, err @@ -4694,6 +4695,7 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb OrganizationID: arg.OrgID, UserID: arg.UserID, IncludeSystem: false, + GithubUserID: 0, })) if err != nil { return database.OrganizationMember{}, err diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 9d84cc9675..6bf1a3e25d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7142,12 +7142,19 @@ WHERE ELSE is_system = false END + -- Filter by github user ID. Note that this requires a join on the users table. + AND CASE + WHEN $4 :: bigint != 0 THEN + users.github_com_user_id = $4 + ELSE true + END ` type OrganizationMembersParams struct { OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` UserID uuid.UUID `db:"user_id" json:"user_id"` IncludeSystem bool `db:"include_system" json:"include_system"` + GithubUserID int64 `db:"github_user_id" json:"github_user_id"` } type OrganizationMembersRow struct { @@ -7164,7 +7171,12 @@ type OrganizationMembersRow struct { // - Use just 'user_id' to get all orgs a user is a member of // - Use both to get a specific org member row func (q *sqlQuerier) OrganizationMembers(ctx context.Context, arg OrganizationMembersParams) ([]OrganizationMembersRow, error) { - rows, err := q.db.QueryContext(ctx, organizationMembers, arg.OrganizationID, arg.UserID, arg.IncludeSystem) + rows, err := q.db.QueryContext(ctx, organizationMembers, + arg.OrganizationID, + arg.UserID, + arg.IncludeSystem, + arg.GithubUserID, + ) if err != nil { return nil, err } diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index 1c0af01177..c4002259dc 100644 --- a/coderd/database/queries/organizationmembers.sql +++ b/coderd/database/queries/organizationmembers.sql @@ -28,7 +28,13 @@ WHERE WHEN @include_system::bool THEN TRUE ELSE is_system = false - END; + END + -- Filter by github user ID. Note that this requires a join on the users table. + AND CASE + WHEN @github_user_id :: bigint != 0 THEN + users.github_com_user_id = @github_user_id + ELSE true + END; -- name: InsertOrganizationMember :one INSERT INTO diff --git a/coderd/dynamicparameters/render.go b/coderd/dynamicparameters/render.go index 7f0a98f18c..562517b6db 100644 --- a/coderd/dynamicparameters/render.go +++ b/coderd/dynamicparameters/render.go @@ -300,6 +300,7 @@ func WorkspaceOwner(ctx context.Context, db database.Store, org uuid.UUID, owner OrganizationID: org, UserID: ownerID, IncludeSystem: true, + GithubUserID: 0, })) if err != nil { return nil, xerrors.Errorf("fetch user: %w", err) diff --git a/coderd/httpmw/organizationparam.go b/coderd/httpmw/organizationparam.go index c12772a4de..349ffe25e6 100644 --- a/coderd/httpmw/organizationparam.go +++ b/coderd/httpmw/organizationparam.go @@ -181,6 +181,7 @@ func ExtractOrganizationMember(ctx context.Context, auth func(r *http.Request, a OrganizationID: orgID, UserID: user.ID, IncludeSystem: true, + GithubUserID: 0, }) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) diff --git a/coderd/idpsync/role.go b/coderd/idpsync/role.go index b6f555dc1e..0f928b7be2 100644 --- a/coderd/idpsync/role.go +++ b/coderd/idpsync/role.go @@ -93,6 +93,7 @@ func (s AGPLIDPSync) SyncRoles(ctx context.Context, db database.Store, user data OrganizationID: uuid.Nil, UserID: user.ID, IncludeSystem: false, + GithubUserID: 0, }) if err != nil { return xerrors.Errorf("get organizations by user id: %w", err) diff --git a/coderd/members.go b/coderd/members.go index 371b58015b..dd9ce73bba 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -16,6 +16,7 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/codersdk" ) @@ -158,11 +159,16 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { organization = httpmw.OrganizationParam(r) ) - members, err := api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{ - OrganizationID: organization.ID, - UserID: uuid.Nil, - IncludeSystem: false, - }) + params, errors := searchquery.Members(r.URL.Query().Get("q"), organization.ID) + if len(errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid organization member search query.", + Validations: errors, + }) + return + } + + members, err := api.Database.OrganizationMembers(ctx, params) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return diff --git a/coderd/members_test.go b/coderd/members_test.go index bc892bb067..8cfb8be30a 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -1,14 +1,16 @@ package coderd_test import ( + "database/sql" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" "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/database/dbgen" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -52,19 +54,64 @@ func TestDeleteMember(t *testing.T) { func TestListMembers(t *testing.T) { t.Parallel() + client, db := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + _, orgMember := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + _, orgAdmin := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + anotherOrg := dbgen.Organization(t, db, database.Organization{}) + anotherUser := dbgen.User(t, db, database.User{ + GithubComUserID: sql.NullInt64{Valid: true, Int64: 12345}, + }) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: anotherOrg.ID, + UserID: anotherUser.ID, + }) + t.Run("OK", func(t *testing.T) { t.Parallel() - owner := coderdtest.New(t, nil) - first := coderdtest.CreateFirstUser(t, owner) - - client, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID)) ctx := testutil.Context(t, testutil.WaitShort) - members, err := client.OrganizationMembers(ctx, first.OrganizationID) + members, err := client.OrganizationMembers(ctx, owner.OrganizationID) require.NoError(t, err) - require.Len(t, members, 2) + require.Len(t, members, 3) require.ElementsMatch(t, - []uuid.UUID{first.UserID, user.ID}, + []uuid.UUID{owner.UserID, orgMember.ID, orgAdmin.ID}, + db2sdk.List(members, onlyIDs)) + }) + + t.Run("UserID", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + members, err := client.OrganizationMembers(ctx, owner.OrganizationID, codersdk.OrganizationMembersQueryOptionUserID(orgMember.ID)) + require.NoError(t, err) + require.Len(t, members, 1) + require.ElementsMatch(t, + []uuid.UUID{orgMember.ID}, + db2sdk.List(members, onlyIDs)) + }) + + t.Run("IncludeSystem", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + members, err := client.OrganizationMembers(ctx, owner.OrganizationID, codersdk.OrganizationMembersQueryOptionIncludeSystem()) + require.NoError(t, err) + require.Len(t, members, 4) + require.ElementsMatch(t, + []uuid.UUID{owner.UserID, orgMember.ID, orgAdmin.ID, database.PrebuildsSystemUserID}, + db2sdk.List(members, onlyIDs)) + }) + + t.Run("GithubUserID", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + members, err := client.OrganizationMembers(ctx, anotherOrg.ID, codersdk.OrganizationMembersQueryOptionGithubUserID(anotherUser.GithubComUserID.Int64)) + require.NoError(t, err) + require.Len(t, members, 1) + require.ElementsMatch(t, + []uuid.UUID{anotherUser.ID}, db2sdk.List(members, onlyIDs)) }) } diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 5a135584df..d07203a029 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -170,6 +170,50 @@ func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) { return filter, parser.Errors } +func Members(query string, organizationID uuid.UUID) (database.OrganizationMembersParams, []codersdk.ValidationError) { + query = strings.TrimSpace(query) + if query == "" { + return database.OrganizationMembersParams{ + OrganizationID: organizationID, + UserID: uuid.Nil, + IncludeSystem: false, + GithubUserID: 0, + }, nil + } + values, errors := searchTerms(query, func(term string, values url.Values) error { + switch term { + case "user_id": + values.Set("user_id", "") + case "github_user_id": + values.Set("github_user_id", "") + case "include_system": + values.Set("include_system", "") + default: + return xerrors.Errorf("invalid search term: %s", term) + } + return nil + }) + if len(errors) > 0 { + return database.OrganizationMembersParams{ + OrganizationID: organizationID, + UserID: uuid.Nil, + IncludeSystem: false, + GithubUserID: 0, + }, errors + } + + parser := httpapi.NewQueryParamParser() + params := database.OrganizationMembersParams{ + OrganizationID: organizationID, + UserID: parser.UUID(values, uuid.Nil, "user_id"), + IncludeSystem: parser.Boolean(values, false, "include_system"), + GithubUserID: parser.Int64(values, 0, "github_user_id"), + } + parser.ErrorExcessParams(values) + + return params, parser.Errors +} + func Workspaces(ctx context.Context, db database.Store, query string, page codersdk.Pagination, agentInactiveDisconnectTimeout time.Duration) (database.GetWorkspacesParams, []codersdk.ValidationError) { filter := database.GetWorkspacesParams{ AgentInactiveDisconnectTimeoutSeconds: int64(agentInactiveDisconnectTimeout.Seconds()), diff --git a/coderd/users.go b/coderd/users.go index b4b66611b2..1e592d010c 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1236,6 +1236,7 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) { UserID: user.ID, OrganizationID: uuid.Nil, IncludeSystem: false, + GithubUserID: 0, }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/codersdk/users.go b/codersdk/users.go index f65223a666..266ef8c598 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strconv" "strings" "time" @@ -554,9 +555,65 @@ func (c *Client) DeleteOrganizationMember(ctx context.Context, organizationID uu return nil } +type OrganizationMembersQuery struct { + UserID uuid.UUID + IncludeSystem bool + GithubUserID int64 +} + +func (omq OrganizationMembersQuery) AsRequestOption() RequestOption { + return func(r *http.Request) { + q := r.URL.Query() + var sb strings.Builder + if omq.UserID != uuid.Nil { + _, _ = sb.WriteString("user_id:") + _, _ = sb.WriteString(omq.UserID.String()) + _, _ = sb.WriteString(" ") + } + if omq.IncludeSystem { + _, _ = sb.WriteString("include_system:true") + } + if omq.GithubUserID != 0 { + _, _ = sb.WriteString("github_user_id:") + _, _ = sb.WriteString(strconv.FormatInt(omq.GithubUserID, 10)) + _, _ = sb.WriteString(" ") + } + qs := strings.TrimSpace(sb.String()) + if len(qs) == 0 { + return + } + q.Set("q", qs) + r.URL.RawQuery = q.Encode() + } +} + +type OrganizationMembersQueryOption func(*OrganizationMembersQuery) + +func OrganizationMembersQueryOptionUserID(userID uuid.UUID) OrganizationMembersQueryOption { + return func(query *OrganizationMembersQuery) { + query.UserID = userID + } +} + +func OrganizationMembersQueryOptionIncludeSystem() OrganizationMembersQueryOption { + return func(query *OrganizationMembersQuery) { + query.IncludeSystem = true + } +} + +func OrganizationMembersQueryOptionGithubUserID(githubUserID int64) OrganizationMembersQueryOption { + return func(query *OrganizationMembersQuery) { + query.GithubUserID = githubUserID + } +} + // OrganizationMembers lists all members in an organization -func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]OrganizationMemberWithUserData, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/", organizationID), nil) +func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UUID, opts ...OrganizationMembersQueryOption) ([]OrganizationMemberWithUserData, error) { + var query OrganizationMembersQuery + for _, opt := range opts { + opt(&query) + } + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/", organizationID), nil, query.AsRequestOption()) if err != nil { return nil, err } diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 89671e00bd..ea3f6824b7 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -181,6 +181,7 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { OrganizationID: group.OrganizationID, UserID: uuid.MustParse(id), IncludeSystem: false, + GithubUserID: 0, })) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5e798baecf..0519c9c136 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2037,6 +2037,13 @@ export interface OrganizationMemberWithUserData extends OrganizationMember { readonly global_roles: readonly SlimRole[]; } +// From codersdk/users.go +export interface OrganizationMembersQuery { + readonly UserID: string; + readonly IncludeSystem: boolean; + readonly GithubUserID: number; +} + // From codersdk/organizations.go export interface OrganizationProvisionerDaemonsOptions { readonly Limit: number;