From 5acfb83287511544002be1cae0444897399fba4b Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Mon, 31 Oct 2022 22:01:32 +0000 Subject: [PATCH 01/16] Start on backend --- coderd/coderd.go | 1 + coderd/database/databasefake/databasefake.go | 54 ++++++++++++++++++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 52 +++++++++++++++++++ coderd/database/queries/users.sql | 32 ++++++++++++ coderd/users.go | 27 ++++++++++ codersdk/users.go | 8 +++ site/src/api/typesGenerated.ts | 10 ++++ 8 files changed, 185 insertions(+) diff --git a/coderd/coderd.go b/coderd/coderd.go index 43ae621be144b..ce8a9ee0792ca 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -433,6 +433,7 @@ func New(options *Options) *API { ) r.Post("/", api.postUser) r.Get("/", api.users) + r.Get("/count", api.userCount) r.Post("/logout", api.postLogout) // These routes query information about site wide roles. r.Route("/roles", func(r chi.Router) { diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index c6900c316ec1d..1c2db5b1377fb 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -457,6 +457,60 @@ func (q *fakeQuerier) GetActiveUserCount(_ context.Context) (int64, error) { return active, nil } +func (q *fakeQuerier) GetFilteredUserCount(_ context.Context, params database.GetFilteredUserCountParams) (int64, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + users := make([]database.User, len(q.users)) + + if params.Deleted { + tmp := make([]database.User, 0, len(users)) + for _, user := range users { + if user.Deleted { + tmp = append(tmp, user) + } + } + users = tmp + } + + if params.Search != "" { + tmp := make([]database.User, 0, len(users)) + for i, user := range users { + if strings.Contains(strings.ToLower(user.Email), strings.ToLower(params.Search)) { + tmp = append(tmp, users[i]) + } else if strings.Contains(strings.ToLower(user.Username), strings.ToLower(params.Search)) { + tmp = append(tmp, users[i]) + } + } + users = tmp + } + + if len(params.Status) > 0 { + usersFilteredByStatus := make([]database.User, 0, len(users)) + for i, user := range users { + if slice.ContainsCompare(params.Status, user.Status, func(a, b database.UserStatus) bool { + return strings.EqualFold(string(a), string(b)) + }) { + usersFilteredByStatus = append(usersFilteredByStatus, users[i]) + } + } + users = usersFilteredByStatus + } + + if len(params.RbacRole) > 0 && !slice.Contains(params.RbacRole, rbac.RoleMember()) { + usersFilteredByRole := make([]database.User, 0, len(users)) + for i, user := range users { + if slice.OverlapCompare(params.RbacRole, user.RBACRoles, strings.EqualFold) { + usersFilteredByRole = append(usersFilteredByRole, users[i]) + } + } + + users = usersFilteredByRole + } + + return int64(len(users)), nil +} + func (q *fakeQuerier) UpdateUserDeletedByID(_ context.Context, params database.UpdateUserDeletedByIDParams) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 25ab45c21df96..ba1836235d8d2 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -44,6 +44,7 @@ type sqlcQuerier interface { GetDeploymentID(ctx context.Context) (string, error) GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error) GetFileByID(ctx context.Context, id uuid.UUID) (File, error) + GetFilteredUserCount(ctx context.Context, arg GetFilteredUserCountParams) (int64, error) GetGitAuthLink(ctx context.Context, arg GetGitAuthLinkParams) (GitAuthLink, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 58f89cad9fd70..0184323a51084 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3948,6 +3948,58 @@ func (q *sqlQuerier) GetAuthorizationUserRoles(ctx context.Context, userID uuid. return i, err } +const getFilteredUserCount = `-- name: GetFilteredUserCount :one +SELECT + COUNT(*) +FROM + users +WHERE + users.deleted = $1 + -- Start filters + -- Filter by name, email or username + AND CASE + WHEN $2 :: text != '' THEN ( + email ILIKE concat('%', $2, '%') + OR username ILIKE concat('%', $2, '%') + ) + ELSE true + END + -- Filter by status + AND CASE + -- @status needs to be a text because it can be empty, If it was + -- user_status enum, it would not. + WHEN cardinality($3 :: user_status[]) > 0 THEN + status = ANY($3 :: user_status[]) + ELSE true + END + -- Filter by rbac_roles + AND CASE + -- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as everyone is a member. + WHEN cardinality($4 :: text[]) > 0 AND 'member' != ANY($4 :: text[]) + THEN rbac_roles && $4 :: text[] + ELSE true + END +` + +type GetFilteredUserCountParams struct { + Deleted bool `db:"deleted" json:"deleted"` + Search string `db:"search" json:"search"` + Status []UserStatus `db:"status" json:"status"` + RbacRole []string `db:"rbac_role" json:"rbac_role"` +} + +func (q *sqlQuerier) GetFilteredUserCount(ctx context.Context, arg GetFilteredUserCountParams) (int64, error) { + row := q.db.QueryRowContext(ctx, getFilteredUserCount, + arg.Deleted, + arg.Search, + pq.Array(arg.Status), + pq.Array(arg.RbacRole), + ) + var count int64 + err := row.Scan(&count) + return count, err +} + const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 5cf4dd8a12816..2937422d3d93b 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -39,6 +39,38 @@ FROM WHERE status = 'active'::public.user_status AND deleted = false; +-- name: GetFilteredUserCount :one +SELECT + COUNT(*) +FROM + users +WHERE + users.deleted = @deleted + -- Start filters + -- Filter by name, email or username + AND CASE + WHEN @search :: text != '' THEN ( + email ILIKE concat('%', @search, '%') + OR username ILIKE concat('%', @search, '%') + ) + ELSE true + END + -- Filter by status + AND CASE + -- @status needs to be a text because it can be empty, If it was + -- user_status enum, it would not. + WHEN cardinality(@status :: user_status[]) > 0 THEN + status = ANY(@status :: user_status[]) + ELSE true + END + -- Filter by rbac_roles + AND CASE + -- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as everyone is a member. + WHEN cardinality(@rbac_role :: text[]) > 0 AND 'member' != ANY(@rbac_role :: text[]) + THEN rbac_roles && @rbac_role :: text[] + ELSE true + END; + -- name: InsertUser :one INSERT INTO users ( diff --git a/coderd/users.go b/coderd/users.go index 1e1682dbfd912..51e9f6a24995f 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -251,6 +251,33 @@ func (api *API) users(rw http.ResponseWriter, r *http.Request) { render.JSON(rw, r, convertUsers(users, organizationIDsByUserID)) } +func (api *API) userCount(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + query := r.URL.Query().Get("q") + params, errs := userSearchQuery(query) + if len(errs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid user search query.", + Validations: errs, + }) + return + } + + count, err := api.Database.GetFilteredUserCount(ctx, database.GetFilteredUserCountParams{ + Search: params.Search, + Status: params.Status, + RbacRole: params.RbacRole, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserCountResponse{ + Count: count, + }) +} + // Creates a new user. func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/codersdk/users.go b/codersdk/users.go index b2452284a2412..27782ca223ef9 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -47,6 +47,14 @@ type User struct { AvatarURL string `json:"avatar_url"` } +type UserCountRequest struct { + SearchQuery string `json:"q,omitempty"` +} + +type UserCountResponse struct { + Count int64 `json:"count"` +} + type CreateFirstUserRequest struct { Email string `json:"email" validate:"required,email"` Username string `json:"username" validate:"required,username"` diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6b8affde3d827..5797bb22610a8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -734,6 +734,16 @@ export interface User { readonly avatar_url: string } +// From codersdk/users.go +export interface UserCountRequest { + readonly q?: string +} + +// From codersdk/users.go +export interface UserCountResponse { + readonly count: number +} + // From codersdk/users.go export interface UserRoles { readonly roles: string[] From 9b4267813ca265cc780372938eae482a1deffae6 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Mon, 31 Oct 2022 22:46:37 +0000 Subject: [PATCH 02/16] Hook up frontend --- site/src/api/api.ts | 7 ++ site/src/pages/UsersPage/UsersPage.tsx | 22 ++-- site/src/pages/UsersPage/UsersPageView.tsx | 4 +- site/src/testHelpers/entities.ts | 4 + site/src/testHelpers/handlers.ts | 4 +- site/src/xServices/users/usersXService.ts | 114 +++++++++++++++------ 6 files changed, 110 insertions(+), 45 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 3804e83aed2aa..fea185eca4efe 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -107,6 +107,7 @@ export const getUser = async (): Promise => { return response.data } + export const getAuthMethods = async (): Promise => { const response = await axios.get( "/api/v2/users/authmethods", @@ -139,6 +140,12 @@ export const getUsers = async ( return response.data } +export const getUserCount = async (options: TypesGen.UserCountRequest): Promise => { + const url = getURLWithSearchParams("/api/v2/users/count", options) + const response = await axios.get(url.toString()) + return response.data +} + export const getOrganization = async ( organizationId: string, ): Promise => { diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 63384c13118e5..5e41653473ce5 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -46,6 +46,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { userIdToResetPassword, newUserPassword, paginationRef, + count } = usersState.context const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend) @@ -60,7 +61,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { // - users are loading or // - the user can edit the users but the roles are loading const isLoading = - usersState.matches("gettingUsers") || + usersState.matches("users.gettingUsers") || (canEditUsers && rolesState.matches("gettingRoles")) // Fetch roles on component mount @@ -81,6 +82,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { { navigate( "/workspaces?filter=" + @@ -107,7 +109,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { }) }} error={getUsersError} - isUpdatingUserRoles={usersState.matches("updatingUserRoles")} + isUpdatingUserRoles={usersState.matches("users.updatingUserRoles")} isLoading={isLoading} canEditUsers={canEditUsers} filter={usersState.context.filter} @@ -119,8 +121,8 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { {userToBeDeleted && ( { @@ -135,8 +137,8 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { { @@ -156,8 +158,8 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { { @@ -175,10 +177,10 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { /> { usersSend("CANCEL_USER_PASSWORD_RESET") }} diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 9a80140a1d048..f9104e506a3de 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -12,6 +12,7 @@ export const Language = { } export interface UsersPageViewProps { users?: TypesGen.User[] + count?: number roles?: TypesGen.AssignableRoles[] filter?: string error?: unknown @@ -33,6 +34,7 @@ export interface UsersPageViewProps { export const UsersPageView: FC> = ({ users, + count, roles, onSuspendUser, onDeleteUser, @@ -76,7 +78,7 @@ export const UsersPageView: FC> = ({ isLoading={isLoading} /> - + ) } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index f0f51152b963b..bc1608efe1d20 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -82,6 +82,10 @@ export const MockUser: TypesGen.User = { last_seen_at: "", } +export const MockUserCountResponse: TypesGen.UserCountResponse = { + count: 26 +} + export const MockUserAdmin: TypesGen.User = { id: "test-user", username: "TestUser", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 599e6d88a00d3..d9a9060a743a0 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -74,8 +74,8 @@ export const handlers = [ ctx.json([M.MockUser, M.MockUser2, M.SuspendedMockUser]), ) }), - rest.post("/api/v2/users", async (req, res, ctx) => { - return res(ctx.status(200), ctx.json(M.MockUser)) + rest.get("/api/v2/users/count", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockUserCountResponse)) }), rest.get("/api/v2/users/me/organizations", (req, res, ctx) => { return res(ctx.status(200), ctx.json([M.MockOrganization])) diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index 764d3124fab75..b11faefc33103 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -54,6 +54,8 @@ export interface UsersContext { updateUserRolesError?: Error | unknown paginationContext: PaginationContext paginationRef: PaginationMachineRef + count: number + getCountError: Error | unknown } export type UsersEvent = @@ -86,39 +88,76 @@ export type UsersEvent = | { type: "UPDATE_PAGE"; page: string } export const usersMachine = - /** @xstate-layout N4IgpgJg5mDOIC5QFdZgE6wMoBcCGOYAdLPujgJYB2UACnlNQRQPZUDEA2gAwC6ioAA4tYFSmwEgAHogCMADiLcA7AFYVAFgCcW5bNkaAbACZDAGhABPOVsNEAzN1XGDhw92PduW+wF9fFqgY2PiERDA4lDQAqmiY7BBsxNQAbiwA1sQRscE8-EggwqLiVJIyCPKyRHryyk4m9lrGzfYW1ggaGqpERvaq9soasqrKfgEgQZi4BFlgkdRQOfEY6CzoRIIANgQAZmsAtuFzS7B5kkVirKUF5QC0ssrGRLV1rs2VyrptiJ3K1cbyHQaUyqSoDVT+QJxEIzIgUCCbMDsLDRLC0ACiADkACIAfVR6IASmcChcSmVEMZOkRDPpgYZ5J4NNx5PZWlY5A8qvZjH15Nx7IZVFp5KoNJCJtDpmF4Yj2Nj0QAZdEAFXR+KwRJJQhElwkN0phi0RAMqncjIZo0Z3wqtSIOgZGg+3HNsglkxhMoRSIAggBhFUASQAaj61RqtXxzrryQaEKZ7Pb7KLBQCTLJ3Kobe5E2adINjE1DE7jO6paFkt72IT0ZqVRHCbjaD6sFgAOoAeUJ2O1hRjVwp8dkxq6A2UFuTyhMWY5CFcdlkhbB8n5vNBZeC0srcuitGxYfVBMbhI7yqwvbJA7jtwBfwF3lG-WHPhtjO4NIZ7lk9Q0fI3UwrOEq13fdw2bABxdEL37fVQDuHk7BUTQnEcAEdGzLoaQtdQ+h8Ywp3-T1tyRECD1xAAxQNFTVYko1JGDrjgxB7m6LQjABfCtBUYduFkV9h2qAZv3w5wuIhcYPS3IgAGM2B2Ch0H2JYsFQQQwCoUQ2HYP0O0xSjCQAWQbXEUTRLEsEDXToOKK8mNtRMCyFXpPjcLQbX0FcTU+EtnFwhlCKk2SqHkxTlNU9TNI4P0fUxP0lWM0yMUxCyrLonUbNg6REFBbpmU+LiHipbhOnc-D3xXUURVpWlioCwCgpCpS4mxMBERKbTdP0oyj1xBVlTVay9UYrKKh8e1vHkbRgRUBobS0fQelFRc9F-Nj5EMOrYQahSmowFq2qubSYrixVjL61UoLSvsMuG8paSeYZ7AzNlDHHN65rcGliv5AVPjNExNrCbbQriH1pMoFJmC0nS9MDQzjP9INQyDVL8nSobBxeD8BnsTops6NzZ0MXHFrZKk2NUfQNok8strknaljBiGoai474p6xGQzDSzMUG2M7OFVjuMXQVeNUSmbTqRNlt-NwnuUR5xRpzdANgcKqAgBYlgSJI4SoNJMhIdWICWPnbJG4ZFxNcX+XHbxhzUOa6i8o1vBFdRaTdZWANhNXYDUjWtbidgVjWDZthwPZFKN-31JNuIzcy8oPPfEYXEm3R8NGPjZ1kRwNCUYU8-w2Xv3EqEVdhCBWrmIOMB1qhkn1jJiGrtqwFNq7LyTuRKeNAxmS0UENEeXjJZGapmRGEfVrTwHW5rqJFmD0P1i2XYDiINu5g7hOu4Ywd9CH6oWVFV4uItdzeONMU6TZWpFxH+eiDwcGKEhpftcSRu9YN4hX+ZoQTuaNroYzjJbRMPgeQujUOob85hZxmlTrSEYLpTAKDNM-AB79mAxBXugVYa8I5R0ONgj+u8MCJ1upyWwk9vwsmTMVAw48C7aAZFyMUU59DP2BrtdA9BYCwAAO5rAgISOAcwOqw3hj1ZsrZOzdlxDWOsVDMZimqPAgwxhKbixFO5HCX1ibaABJTV6ygeH0xBhgARwjRHiLQDgI6sV2aakbHI9sXY8TKNVKouMad7T9FsMTI0Csnr6LtAYUWahFxMnMd7IiRB0ASPmHg6xeBBEiPQBABuTc-6JOSUsGxmSIC+LsnndRecmjqD0KMfC7lxwLlMLofot5RTUwrj7MISSHGfziEU0RIcCFh3XpHTe3Tjh9PSbYrJpSLYPGNCoRwoJ3iihXO5IUTwRS6BqMWAYwJn7IEEBAXBy8MCEhYIiWAOTf4tyIIc45QC4jnMubMu4VoTTFSFG+SpGgPp-CnFA4SzhtDl0lJXMI9yTlLGeXAQZhDw4b2jpCx5ZyLlwFecxL8Dghi6FegoPogp+LGlGHoDwahCxOH8OMKgLBq7wAKJJVWZAl70EYFQFm0YbqDluM4E+nxPgsmLCKXkr5uiFktDob8zJXpKw6QkiIvTgicrAXZW47geiSqGHigEi4ZztBBEQfobIjWeBiVoZ+sowDKv5hbHkzwmiDB5M0NwYpfmzjUAXZwZ9OjfjYk9CxwUGZxBUrHDS5tu7UIQKCRQXEhgjzluUnO7Qj4F0qBgqBeF2lgs6cQXhSx9q10yhGwcJgnhGlpI8IeT5xZzQWk6dQ6h+QPHFmMOVgVLF8KZjgm1xa4zVUnkaZkuMvBDD1YgIxpMeRTUphmZ+fsA6a1Sega15tk4ZkUEXV2TgxTCnkO5YYfwnoKHFVSSccS22AW3oq5d9EuXgIUFUHQvFTCvAMHo2cK4C4eCEn0dwXQHhYLfh-OuN70Y2rXWNbRDribzSNUm8dngTTfkBLyFB8aA2NUKVM4p9i5grp7lG+ahq1AijzsCcl8G5wGPcEYpoS0zHP3GSk05-DsOiPw5GjyC5nBeFZOaDwY65xsI-GoWoDb1pqAOUcqFTy0X0rA6u5inR3xrlsCyOoAohhzSMPaGpr03C8TZPPDj3KGkfKMMswzbEbS3AcgKRcSEnArkGPIKlvggA */ + /** @xstate-layout N4IgpgJg5mDOIC5QFdZgE6wMoBcCGOYAdAMYD2yAdjkTDjgJaVQDCF1AxBGZcUwG5kA1sToBVNOjZUcAbQAMAXUSgADmVgNGPFSAAeiAIwBWQ-KIBmABwAWCwHYrAJhcA2YwE4ANCACeiZ0Miew8LBxsrCw8Pe1MAXzifVAxsfEJSdho6RmZpTgx0MnQiVQAbAgAzIoBbWjAcCQw8uSVddU1tSl0DBEMneSDXC0MrQ2G3KysffwRo4yJjYxtlh1c1m2NXBKTJVIJichkOMQAFABEAQQAVAFEAfQAxAEkAGVuAJQVlJBB2rQYdD8en1DB4iE5NvYbCMokNvH5EK4NkRbI5XIYbE5DGNDPZtiBkphcPsiITYERYPh0DkoCc8FAmAQAZQOF82hp-oDQD0ALRQ8zyEIWYz2dwhJw2VzTRGOIiGdyjJwWSXyZV4xIE3bE9Jkur0JhQRqYLg8PiUQQiPVG2Bsn5-TrdRA84wuYLyVxWexRDy2Vz2ezShCRGwLRZ+0VOWzGT34sna4i67IG60cApFErlHBVdC1cS7W1qDkOoFOxxOSxK7HySKReSmQNWd0LEJ9L1OWLGeQeWNatIJ3ZEBgQUpgDhYMRYE43AByZzuE5un1adqLzMdCB5SNcKOhNdcddiHsDGJCKOjHjGStsZicPZS8dJA6HI44ZxuLxut3nWEXBd+q65fQnSReZrEcKwfSsJZNgsY9hkGNUIklRZBQsO8iT7R8UkHYdRwuFgrieAA1a57gXJdvkLDo1xLDcQKId1llcGIoUiJx4RmbEbDBP1QXFGIIVFdC9h1J9cI4d4bh-K5v0XO4TguLAsAAdQAeXeM4-3tGjuWAmx7Dlex2MlCEPCGGxjyGbcbEFDxBRskx7FxYSH11Z9R1OS4v3Iu53lUj8sC0gCulonkfW3eUsRiDEfWMaxjxGAy4rDD1wwxLYNTjTC3PEzzSPki4AHEbiC6jAN5DxJTlDEIOhD15TsQMPE7Bi1n9Vs7MbdUdnvbKB3ISgKgYHMjSwVBVDAShNB4DgWFU6dnneABZWT3jucdJxnLAnnm0rORC3SNzMCxgghCw1ja7ioUMY9uKsBiJU2aElVFDEXL67CBqGkbJDG2AJqm5lZouacWHfVb1onKdp223blyo-b1x5Gz7rsMZ3XkeQbLA49NnmDwJXdF1qz9DKeowkldS+4bqiNM4wBHTpZvmxaVp8t8P1uPbi0OnkuIWJEoQ2DY4vsQU4OFOUlXkFw4osLtIneyn+p4b7ackenGaBlgQbBl4IY5z8Svh-8yoOoCNwcAyYjssJG0lFiLIRXobPLaIImDOFFiV0TPtVmmjQuEhGH4JkZrmhanmWiH8MIkjCLhyjTcR0KQSIAmhlFRsBkjetnexIYiFcdsBn9OEZbJzVeuVv3BoDyQg5DsOWR10HwZ82PiOuHbp25nSLZ5ax7sbSYsRcUZroDfOlW3CVIzM1UK5dH3+2w2BxsmiBk0kE1eEHc1hGIdf-s3o0+-KxAy4WAnVUjEY62hW7C-YjHOyWUYYhXrDMApDfKC35gRpUzoEKMUMolQai-xPv-M+JttIXwQKYAyZYGqhCGC4G6+dzrzFMJEHE1ZpaVyyjXH+EAGb1G3hgXeZoLTEDIYzMAsCk7wPNj0RwJ1oyqkSo4aITg4LYiLohX0KFYhf11PQihgCd5pjAZmbMtQJGECYeyM2644puxGJMJYJkHAcSMFiEM1ZMaRCHpiSUYiBx4GDgwUONIgHcD3gIQ+RArFNyUZIc+rDL6ynOpVAYSIIJlmPF2csY8RieGatCMYFjsKuJsUyKRVCZEZggTmFx1jbGMI8XA4KailQLHlJ2L0LYInBPlBWVU3ExjCkqTEn+1MfoYDpLAWAAB3IoEB3hwHqMzSO0cfIKSUmpDSvkpKfk8UjSqRB9K1X8ZKdwMRLJehRFGOsfirDMTqeSBp6sml4Bae09AnTuk4GBm3fWAzFIqXUnOSS0kJmhUxAZQIWjJhmBdHo3osQTomNMJjGIEE1hbKIOgE5djJDNLaR06h+9aEgpOUaSFhyIAPMOi6E6VYx7u2LljY8IQQwXiMshYu78bDAtBWgfUiT0BIuhck8BWZIEUvqIi-ZUKjmootm-HcUYqx1kjBsSyfQUSxDFtozO1hgXIFUBABJhpJDvDICOWAMKnGWmlbK9xGBFXKs5byPo8si4j0KcsMVx4JSDGvPpSCfQzJSplXKo0Oq4DANASkxlaSNX7CdUquAeqnRjFCNMuyr02JO04r44IJ5+jy09C6dUGpKBkDIfAH4xD0iHGoDhEcKiU6HRMNESwywuwDEqh6eKztmpBFxFBFY8tzpDC-pmrI9QaTNFzTzC22J8lz2hO-Wy2JAw8LlOiT0VazK+ibZkDt-dgSCnmL2kwEQB2YJmGGHcaJkqRj9GhTKvYSHkkpHgakBo6QMkoM3GdCC+aYyCHWasFgXD9BcCEQMSIQzywhMXZw+lVREP3b7H+SZqWpoRp23kxdQkQQhJ6dsEYp4zDWPdAmEFGwE2hLw4F7kr1eLohecEazoxREFGLWC+csYhickhFD3p3TAp2aNP+01zYsKRudcs2dGINrhGEXGqoi1OU9PLfcOd6P+0aegTWFDAKsdCuxhifEnJ+icCBVdRgwjzANTLM68tYpibrhJxu8TO2yd5uda2HpYp+gcjLcNRh5QnWLmLDYlUoSxDJXu6ugHD1-wAfKjAOH1wjDBOh8z0Z0r+mMLdJyCwNhOS7EMdR3ZPMU280QRRlD0CBdosF8E98ohl0bH0KY08YhF2lpU5YUFJjAribYzL2X82nigg+5dthmrFwSpLNYMtlTth6xefTatWUHI6V0yljWB7YKLi6Eu8ozIuhK5xHdKzljcRFA4JUxhyVgsy7So5k22EhnYt+3ORlFgYjxcXQynCRZmGOvazVmXnWgeTuBgNjE5Sobiu6R6dnejCjvS2GW0QLxvRSyJVemBDsBuWEEbigpR2Pv+4sUCMtNj8o2VjZL5NIcw6OhEMECPQ3I8DPMyw6PPQLKhPubbCQ4hAA */ createMachine( { - tsTypes: {} as import("./usersXService.typegen").Typegen0, - schema: { - context: {} as UsersContext, - events: {} as UsersEvent, - services: {} as { - getUsers: { - data: TypesGen.User[] - } - createUser: { - data: TypesGen.User - } - suspendUser: { - data: TypesGen.User - } - deleteUser: { - data: undefined - } - activateUser: { - data: TypesGen.User - } - updateUserPassword: { - data: undefined - } - updateUserRoles: { - data: TypesGen.User - } + tsTypes: {} as import("./usersXService.typegen").Typegen0, + schema: { + context: {} as UsersContext, + events: {} as UsersEvent, + services: {} as { + getUsers: { + data: TypesGen.User[] + } + createUser: { + data: TypesGen.User + } + suspendUser: { + data: TypesGen.User + } + deleteUser: { + data: undefined + } + activateUser: { + data: TypesGen.User + } + updateUserPassword: { + data: undefined + } + updateUserRoles: { + data: TypesGen.User + } + getUserCount: { + data: TypesGen.UserCountResponse + } + }, + }, + predictableActionArguments: true, + id: "usersState", + type: "parallel", + states: { + count: { + initial: "gettingCount", + states: { + idle: {}, + gettingCount: { + entry: "clearGetCountError", + invoke: { + src: "getUserCount", + id: "getUserCount", + onDone: [ + { + target: "idle", + actions: "assignCount", + }, + ], + onError: [ + { + target: "idle", + actions: "assignGetCountError", + }, + ], + }, + }, + }, + on: { + UPDATE_FILTER: { + target: ".gettingCount", + actions: ["assignFilter", "sendResetPage"], }, }, - predictableActionArguments: true, - id: "usersState", + }, + users: { initial: "startingPagination", states: { startingPagination: { @@ -180,9 +219,6 @@ export const usersMachine = target: "gettingUsers", actions: "updateURL", }, - UPDATE_FILTER: { - actions: ["assignFilter", "sendResetPage"], - }, }, }, confirmUserSuspension: { @@ -334,6 +370,8 @@ export const usersMachine = }, }, }, + }, +}, { services: { // Passing API.getUsers directly does not invoke the function properly @@ -347,6 +385,9 @@ export const usersMachine = limit, }) }, + getUserCount: (context) => { + return API.getUserCount(queryToFilter(context.filter)) + }, suspendUser: (context) => { if (!context.userIdToSuspend) { throw new Error("userIdToSuspend is undefined") @@ -394,6 +435,15 @@ export const usersMachine = assignUsers: assign({ users: (_, event) => event.data, }), + assignCount: assign({ + count: (_, event) => event.data.count + }), + assignGetCountError: assign({ + getCountError: (_, event) => event.data + }), + clearGetCountError: assign({ + getCountError: (_) => undefined + }), assignFilter: assign({ filter: (_, event) => event.query, }), From 97027d23aebf7ad5cb090c49188ca4fe31709922 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 1 Nov 2022 17:02:04 +0000 Subject: [PATCH 03/16] Add to frontend test --- site/src/pages/UsersPage/UsersPage.test.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index 766581ca2dcf2..5cbd98057a84a 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -240,7 +240,7 @@ describe("UsersPage", () => { describe("pagination", () => { it("goes to next and previous page", async () => { - renderPage() + const { container } = renderPage() const user = userEvent.setup() const mock = jest @@ -248,6 +248,9 @@ describe("UsersPage", () => { .mockResolvedValueOnce([MockUser, MockUser2]) const nextButton = await screen.findByLabelText("Next page") + expect(nextButton).toBeEnabled() + const previousButton = await screen.findByLabelText("Previous page") + expect(previousButton).toBeDisabled() await user.click(nextButton) await waitFor(() => @@ -255,12 +258,15 @@ describe("UsersPage", () => { ) mock.mockClear() - const previousButton = await screen.findByLabelText("Previous page") await user.click(previousButton) await waitFor(() => expect(API.getUsers).toBeCalledWith({ offset: 0, limit: 25, q: "" }), ) + + const pageButtons = await container.querySelectorAll(`button[name="Page button"]`) + // count handler says there are 2 pages of results + expect(pageButtons.length).toBe(2) }) }) From 486839b76e81038aa8dc29c6291ca05e49faca69 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 1 Nov 2022 20:57:09 +0000 Subject: [PATCH 04/16] Add go test, wip --- coderd/users_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++++ codersdk/users.go | 25 ++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/coderd/users_test.go b/coderd/users_test.go index bb55b909faf5b..079ea512e57bb 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1255,6 +1255,74 @@ func TestGetUsers(t *testing.T) { }) } +func TestGetFilteredUserCount(t *testing.T) { + t.Parallel() + t.Run("AllUsers", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "alice@email.com", + Username: "alice", + Password: "password", + OrganizationID: user.OrganizationID, + }) + // No params is all users + count, err := client.UserCount(ctx, codersdk.UserCountRequest{}) + require.NoError(t, err) + require.Equal(t, count, 2) + }) + t.Run("ActiveUsers", func(t *testing.T) { + t.Parallel() + active := make([]codersdk.User, 0) + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + firstUser, err := client.User(ctx, first.UserID.String()) + require.NoError(t, err, "") + active = append(active, firstUser) + + // Alice will be suspended + alice, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "alice@email.com", + Username: "alice", + Password: "password", + OrganizationID: first.OrganizationID, + }) + require.NoError(t, err) + + bruno, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "bruno@email.com", + Username: "bruno", + Password: "password", + OrganizationID: first.OrganizationID, + }) + require.NoError(t, err) + active = append(active, bruno) + + _, err = client.UpdateUserStatus(ctx, alice.Username, codersdk.UserStatusSuspended) + require.NoError(t, err) + + count, err := client.UserCount(ctx, codersdk.UserCountRequest{ + Status: codersdk.UserStatusActive, + }) + require.NoError(t, err) + require.Equal(t, count, 1) + }) +} + +func TestFilteredUserCount(t *testing.T) { + t.Parallel() + +} + func TestPostTokens(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/codersdk/users.go b/codersdk/users.go index 27782ca223ef9..d20f7fba8fe11 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -353,6 +353,31 @@ func (c *Client) Users(ctx context.Context, req UsersRequest) ([]User, error) { return users, json.NewDecoder(res.Body).Decode(&users) } +func (c *Client) UserCount(ctx context.Context, req UserCountRequest) (UserCountResponse, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/count", nil, + func(r *http.Request) { + q := r.URL.Query() + var params []string + if req.SearchQuery != "" { + params = append(params, req.SearchQuery) + } + q.Set("q", strings.Join(params, " ")) + r.URL.RawQuery = q.Encode() + }, + ) + if err != nil { + return UserCountResponse{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return UserCountResponse{}, readBodyAsError(res) + } + + var count UserCountResponse + return count, nil +} + // OrganizationsByUser returns all organizations the user is a member of. func (c *Client) OrganizationsByUser(ctx context.Context, user string) ([]Organization, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations", user), nil) From 3a8e4bb70798649c7900b57af2f1df2ee51c4a56 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 1 Nov 2022 21:36:39 +0000 Subject: [PATCH 05/16] Fix some test bugs --- coderd/users_test.go | 13 ++++--------- codersdk/users.go | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/coderd/users_test.go b/coderd/users_test.go index 079ea512e57bb..a571ba52e5a86 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1272,9 +1272,9 @@ func TestGetFilteredUserCount(t *testing.T) { OrganizationID: user.OrganizationID, }) // No params is all users - count, err := client.UserCount(ctx, codersdk.UserCountRequest{}) + response, err := client.UserCount(ctx, codersdk.UserCountRequest{}) require.NoError(t, err) - require.Equal(t, count, 2) + require.Equal(t, 2, int(response.Count)) }) t.Run("ActiveUsers", func(t *testing.T) { t.Parallel() @@ -1310,19 +1310,14 @@ func TestGetFilteredUserCount(t *testing.T) { _, err = client.UpdateUserStatus(ctx, alice.Username, codersdk.UserStatusSuspended) require.NoError(t, err) - count, err := client.UserCount(ctx, codersdk.UserCountRequest{ + response, err := client.UserCount(ctx, codersdk.UserCountRequest{ Status: codersdk.UserStatusActive, }) require.NoError(t, err) - require.Equal(t, count, 1) + require.Equal(t, 1, int(response.Count)) }) } -func TestFilteredUserCount(t *testing.T) { - t.Parallel() - -} - func TestPostTokens(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/codersdk/users.go b/codersdk/users.go index d20f7fba8fe11..c2162a6afd18f 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -48,6 +48,12 @@ type User struct { } type UserCountRequest struct { + Search string `json:"search,omitempty" typescript:"-"` + // Filter users by status. + Status UserStatus `json:"status,omitempty" typescript:"-"` + // Filter users that have the given role. + Role string `json:"role,omitempty" typescript:"-"` + SearchQuery string `json:"q,omitempty"` } @@ -358,6 +364,15 @@ func (c *Client) UserCount(ctx context.Context, req UserCountRequest) (UserCount func(r *http.Request) { q := r.URL.Query() var params []string + if req.Search != "" { + params = append(params, req.Search) + } + if req.Status != "" { + params = append(params, "status:"+string(req.Status)) + } + if req.Role != "" { + params = append(params, "role:"+req.Role) + } if req.SearchQuery != "" { params = append(params, req.SearchQuery) } @@ -375,7 +390,7 @@ func (c *Client) UserCount(ctx context.Context, req UserCountRequest) (UserCount } var count UserCountResponse - return count, nil + return count, json.NewDecoder(res.Body).Decode(&count) } // OrganizationsByUser returns all organizations the user is a member of. From f08358036f9a0e0eec7bf4aa6ff6e91db87f4bdf Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 2 Nov 2022 17:33:11 +0000 Subject: [PATCH 06/16] Fix test --- coderd/users_test.go | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/coderd/users_test.go b/coderd/users_test.go index a571ba52e5a86..e708380f99710 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1278,16 +1278,14 @@ func TestGetFilteredUserCount(t *testing.T) { }) t.Run("ActiveUsers", func(t *testing.T) { t.Parallel() - active := make([]codersdk.User, 0) client := coderdtest.New(t, nil) first := coderdtest.CreateFirstUser(t, client) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - firstUser, err := client.User(ctx, first.UserID.String()) + _, err := client.User(ctx, first.UserID.String()) require.NoError(t, err, "") - active = append(active, firstUser) // Alice will be suspended alice, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ @@ -1298,15 +1296,6 @@ func TestGetFilteredUserCount(t *testing.T) { }) require.NoError(t, err) - bruno, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "bruno@email.com", - Username: "bruno", - Password: "password", - OrganizationID: first.OrganizationID, - }) - require.NoError(t, err) - active = append(active, bruno) - _, err = client.UpdateUserStatus(ctx, alice.Username, codersdk.UserStatusSuspended) require.NoError(t, err) From 424358ef15d90f37c3343c38953ae43298a8d505 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 2 Nov 2022 18:30:51 +0000 Subject: [PATCH 07/16] Format --- site/src/api/api.ts | 5 +- site/src/pages/UsersPage/UsersPage.test.tsx | 4 +- site/src/pages/UsersPage/UsersPage.tsx | 2 +- site/src/testHelpers/entities.ts | 2 +- site/src/xServices/users/usersXService.ts | 511 ++++++++++---------- 5 files changed, 265 insertions(+), 259 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index fea185eca4efe..bd7695aae490f 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -107,7 +107,6 @@ export const getUser = async (): Promise => { return response.data } - export const getAuthMethods = async (): Promise => { const response = await axios.get( "/api/v2/users/authmethods", @@ -140,7 +139,9 @@ export const getUsers = async ( return response.data } -export const getUserCount = async (options: TypesGen.UserCountRequest): Promise => { +export const getUserCount = async ( + options: TypesGen.UserCountRequest, +): Promise => { const url = getURLWithSearchParams("/api/v2/users/count", options) const response = await axios.get(url.toString()) return response.data diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index 5cbd98057a84a..a5f5cf7668442 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -264,7 +264,9 @@ describe("UsersPage", () => { expect(API.getUsers).toBeCalledWith({ offset: 0, limit: 25, q: "" }), ) - const pageButtons = await container.querySelectorAll(`button[name="Page button"]`) + const pageButtons = await container.querySelectorAll( + `button[name="Page button"]`, + ) // count handler says there are 2 pages of results expect(pageButtons.length).toBe(2) }) diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index d3f39c4ebb11d..22ef21f8d4277 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -50,7 +50,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { userIdToResetPassword, newUserPassword, paginationRef, - count + count, } = usersState.context const { updateUsers: canEditUsers } = usePermissions() diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 3d6e188a2882a..8379cae01321c 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -83,7 +83,7 @@ export const MockUser: TypesGen.User = { } export const MockUserCountResponse: TypesGen.UserCountResponse = { - count: 26 + count: 26, } export const MockUserAdmin: TypesGen.User = { diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index e5daf6c3531f9..8c429c82501bb 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -106,288 +106,291 @@ export const usersMachine = /** @xstate-layout N4IgpgJg5mDOIC5QFdZgE6wMoBcCGOYAdAMYD2yAdjkTDjgJaVQDCF1AxBGZcUwG5kA1sToBVNOjZUcAbQAMAXUSgADmVgNGPFSAAeiAIwBWQ-KIBmABwAWCwHYrAJhcA2YwE4ANCACeiZ0Miew8LBxsrCw8Pe1MAXzifVAxsfEJSdho6RmZpTgx0MnQiVQAbAgAzIoBbWjAcCQw8uSVddU1tSl0DBEMneSDXC0MrQ2G3KysffwRo4yJjYxtlh1c1m2NXBKTJVIJichkOMQAFABEAQQAVAFEAfQAxAEkAGVuAJQVlJBB2rQYdD8en1DB4iE5NvYbCMokNvH5EK4NkRbI5XIYbE5DGNDPZtiBkphcPsiITYERYPh0DkoCc8FAmAQAZQOF82hp-oDQD0ALRQ8zyEIWYz2dwhJw2VzTRGOIiGdyjJwWSXyZV4xIE3bE9Jkur0JhQRqYLg8PiUQQiPVG2Bsn5-TrdRA84wuYLyVxWexRDy2Vz2ezShCRGwLRZ+0VOWzGT34sna4i67IG60cApFErlHBVdC1cS7W1qDkOoFOxxOSxK7HySKReSmQNWd0LEJ9L1OWLGeQeWNatIJ3ZEBgQUpgDhYMRYE43AByZzuE5un1adqLzMdCB5SNcKOhNdcddiHsDGJCKOjHjGStsZicPZS8dJA6HI44ZxuLxut3nWEXBd+q65fQnSReZrEcKwfSsJZNgsY9hkGNUIklRZBQsO8iT7R8UkHYdRwuFgrieAA1a57gXJdvkLDo1xLDcQKId1llcGIoUiJx4RmbEbDBP1QXFGIIVFdC9h1J9cI4d4bh-K5v0XO4TguLAsAAdQAeXeM4-3tGjuWAmx7Dlex2MlCEPCGGxjyGbcbEFDxBRskx7FxYSH11Z9R1OS4v3Iu53lUj8sC0gCulonkfW3eUsRiDEfWMaxjxGAy4rDD1wwxLYNTjTC3PEzzSPki4AHEbiC6jAN5DxJTlDEIOhD15TsQMPE7Bi1n9Vs7MbdUdnvbKB3ISgKgYHMjSwVBVDAShNB4DgWFU6dnneABZWT3jucdJxnLAnnm0rORC3SNzMCxgghCw1ja7ioUMY9uKsBiJU2aElVFDEXL67CBqGkbJDG2AJqm5lZouacWHfVb1onKdp223blyo-b1x5Gz7rsMZ3XkeQbLA49NnmDwJXdF1qz9DKeowkldS+4bqiNM4wBHTpZvmxaVp8t8P1uPbi0OnkuIWJEoQ2DY4vsQU4OFOUlXkFw4osLtIneyn+p4b7ackenGaBlgQbBl4IY5z8Svh-8yoOoCNwcAyYjssJG0lFiLIRXobPLaIImDOFFiV0TPtVmmjQuEhGH4JkZrmhanmWiH8MIkjCLhyjTcR0KQSIAmhlFRsBkjetnexIYiFcdsBn9OEZbJzVeuVv3BoDyQg5DsOWR10HwZ82PiOuHbp25nSLZ5ax7sbSYsRcUZroDfOlW3CVIzM1UK5dH3+2w2BxsmiBk0kE1eEHc1hGIdf-s3o0+-KxAy4WAnVUjEY62hW7C-YjHOyWUYYhXrDMApDfKC35gRpUzoEKMUMolQai-xPv-M+JttIXwQKYAyZYGqhCGC4G6+dzrzFMJEHE1ZpaVyyjXH+EAGb1G3hgXeZoLTEDIYzMAsCk7wPNj0RwJ1oyqkSo4aITg4LYiLohX0KFYhf11PQihgCd5pjAZmbMtQJGECYeyM2644puxGJMJYJkHAcSMFiEM1ZMaRCHpiSUYiBx4GDgwUONIgHcD3gIQ+RArFNyUZIc+rDL6ynOpVAYSIIJlmPF2csY8RieGatCMYFjsKuJsUyKRVCZEZggTmFx1jbGMI8XA4KailQLHlJ2L0LYInBPlBWVU3ExjCkqTEn+1MfoYDpLAWAAB3IoEB3hwHqMzSO0cfIKSUmpDSvkpKfk8UjSqRB9K1X8ZKdwMRLJehRFGOsfirDMTqeSBp6sml4Bae09AnTuk4GBm3fWAzFIqXUnOSS0kJmhUxAZQIWjJhmBdHo3osQTomNMJjGIEE1hbKIOgE5djJDNLaR06h+9aEgpOUaSFhyIAPMOi6E6VYx7u2LljY8IQQwXiMshYu78bDAtBWgfUiT0BIuhck8BWZIEUvqIi-ZUKjmootm-HcUYqx1kjBsSyfQUSxDFtozO1hgXIFUBABJhpJDvDICOWAMKnGWmlbK9xGBFXKs5byPo8si4j0KcsMVx4JSDGvPpSCfQzJSplXKo0Oq4DANASkxlaSNX7CdUquAeqnRjFCNMuyr02JO04r44IJ5+jy09C6dUGpKBkDIfAH4xD0iHGoDhEcKiU6HRMNESwywuwDEqh6eKztmpBFxFBFY8tzpDC-pmrI9QaTNFzTzC22J8lz2hO-Wy2JAw8LlOiT0VazK+ibZkDt-dgSCnmL2kwEQB2YJmGGHcaJkqRj9GhTKvYSHkkpHgakBo6QMkoM3GdCC+aYyCHWasFgXD9BcCEQMSIQzywhMXZw+lVREP3b7H+SZqWpoRp23kxdQkQQhJ6dsEYp4zDWPdAmEFGwE2hLw4F7kr1eLohecEazoxREFGLWC+csYhickhFD3p3TAp2aNP+01zYsKRudcs2dGINrhGEXGqoi1OU9PLfcOd6P+0aegTWFDAKsdCuxhifEnJ+icCBVdRgwjzANTLM68tYpibrhJxu8TO2yd5uda2HpYp+gcjLcNRh5QnWLmLDYlUoSxDJXu6ugHD1-wAfKjAOH1wjDBOh8z0Z0r+mMLdJyCwNhOS7EMdR3ZPMU280QRRlD0CBdosF8E98ohl0bH0KY08YhF2lpU5YUFJjAribYzL2X82nigg+5dthmrFwSpLNYMtlTth6xefTatWUHI6V0yljWB7YKLi6Eu8ozIuhK5xHdKzljcRFA4JUxhyVgsy7So5k22EhnYt+3ORlFgYjxcXQynCRZmGOvazVmXnWgeTuBgNjE5Sobiu6R6dnejCjvS2GW0QLxvRSyJVemBDsBuWEEbigpR2Pv+4sUCMtNj8o2VjZL5NIcw6OhEMECPQ3I8DPMyw6PPQLKhPubbCQ4hAA */ createMachine( { - tsTypes: {} as import("./usersXService.typegen").Typegen0, - schema: { - context: {} as UsersContext, - events: {} as UsersEvent, - services: {} as { - getUsers: { - data: TypesGen.User[] - } - createUser: { - data: TypesGen.User - } - suspendUser: { - data: TypesGen.User - } - deleteUser: { - data: undefined - } - activateUser: { - data: TypesGen.User - } - updateUserPassword: { - data: undefined - } - updateUserRoles: { - data: TypesGen.User - } - getUserCount: { - data: TypesGen.UserCountResponse - } - }, - }, - predictableActionArguments: true, - id: "usersState", - type: "parallel", - states: { - count: { - initial: "gettingCount", - states: { - idle: {}, - gettingCount: { - entry: "clearGetCountError", - invoke: { - src: "getUserCount", - id: "getUserCount", - onDone: [ - { - target: "idle", - actions: "assignCount", - }, - ], - onError: [ - { - target: "idle", - actions: "assignGetCountError", - }, - ], - }, - }, - }, - on: { - UPDATE_FILTER: { - target: ".gettingCount", - actions: ["assignFilter", "sendResetPage"], + tsTypes: {} as import("./usersXService.typegen").Typegen0, + schema: { + context: {} as UsersContext, + events: {} as UsersEvent, + services: {} as { + getUsers: { + data: TypesGen.User[] + } + createUser: { + data: TypesGen.User + } + suspendUser: { + data: TypesGen.User + } + deleteUser: { + data: undefined + } + activateUser: { + data: TypesGen.User + } + updateUserPassword: { + data: undefined + } + updateUserRoles: { + data: TypesGen.User + } + getUserCount: { + data: TypesGen.UserCountResponse + } }, }, - }, - users: { - initial: "startingPagination", + predictableActionArguments: true, + id: "usersState", + type: "parallel", states: { - startingPagination: { - entry: "assignPaginationRef", - always: { - target: "gettingUsers", - }, - }, - gettingUsers: { - entry: "clearGetUsersError", - invoke: { - src: "getUsers", - id: "getUsers", - onDone: [ - { - target: "idle", - actions: "assignUsers", - }, - ], - onError: [ - { - target: "idle", - actions: [ - "clearUsers", - "assignGetUsersError", - "displayGetUsersErrorMessage", + count: { + initial: "gettingCount", + states: { + idle: {}, + gettingCount: { + entry: "clearGetCountError", + invoke: { + src: "getUserCount", + id: "getUserCount", + onDone: [ + { + target: "idle", + actions: "assignCount", + }, + ], + onError: [ + { + target: "idle", + actions: "assignGetCountError", + }, ], }, - ], - }, - tags: "loading", - }, - idle: { - entry: "clearSelectedUser", - on: { - SUSPEND_USER: { - target: "confirmUserSuspension", - actions: "assignUserToSuspend", - }, - DELETE_USER: { - target: "confirmUserDeletion", - actions: "assignUserToDelete", - }, - ACTIVATE_USER: { - target: "confirmUserActivation", - actions: "assignUserToActivate", - }, - RESET_USER_PASSWORD: { - target: "confirmUserPasswordReset", - actions: [ - "assignUserIdToResetPassword", - "generateRandomPassword", - ], - }, - UPDATE_USER_ROLES: { - target: "updatingUserRoles", - actions: "assignUserIdToUpdateRoles", - }, - UPDATE_PAGE: { - target: "gettingUsers", - actions: "updateURL", }, }, - }, - confirmUserSuspension: { on: { - CONFIRM_USER_SUSPENSION: { - target: "suspendingUser", - }, - CANCEL_USER_SUSPENSION: { - target: "idle", + UPDATE_FILTER: { + target: ".gettingCount", + actions: ["assignFilter", "sendResetPage"], }, }, }, - confirmUserDeletion: { - on: { - CONFIRM_USER_DELETE: { - target: "deletingUser", - }, - CANCEL_USER_DELETE: { - target: "idle", - }, - }, - }, - confirmUserActivation: { - on: { - CONFIRM_USER_ACTIVATION: { - target: "activatingUser", - }, - CANCEL_USER_ACTIVATION: { - target: "idle", - }, - }, - }, - suspendingUser: { - entry: "clearSuspendUserError", - invoke: { - src: "suspendUser", - id: "suspendUser", - onDone: [ - { + users: { + initial: "startingPagination", + states: { + startingPagination: { + entry: "assignPaginationRef", + always: { target: "gettingUsers", - actions: "displaySuspendSuccess", }, - ], - onError: [ - { - target: "idle", - actions: [ - "assignSuspendUserError", - "displaySuspendedErrorMessage", + }, + gettingUsers: { + entry: "clearGetUsersError", + invoke: { + src: "getUsers", + id: "getUsers", + onDone: [ + { + target: "idle", + actions: "assignUsers", + }, + ], + onError: [ + { + target: "idle", + actions: [ + "clearUsers", + "assignGetUsersError", + "displayGetUsersErrorMessage", + ], + }, ], }, - ], - }, - }, - deletingUser: { - entry: "clearDeleteUserError", - invoke: { - src: "deleteUser", - id: "deleteUser", - onDone: [ - { - target: "gettingUsers", - actions: "displayDeleteSuccess", + tags: "loading", + }, + idle: { + entry: "clearSelectedUser", + on: { + SUSPEND_USER: { + target: "confirmUserSuspension", + actions: "assignUserToSuspend", + }, + DELETE_USER: { + target: "confirmUserDeletion", + actions: "assignUserToDelete", + }, + ACTIVATE_USER: { + target: "confirmUserActivation", + actions: "assignUserToActivate", + }, + RESET_USER_PASSWORD: { + target: "confirmUserPasswordReset", + actions: [ + "assignUserIdToResetPassword", + "generateRandomPassword", + ], + }, + UPDATE_USER_ROLES: { + target: "updatingUserRoles", + actions: "assignUserIdToUpdateRoles", + }, + UPDATE_PAGE: { + target: "gettingUsers", + actions: "updateURL", + }, }, - ], - onError: [ - { - target: "idle", - actions: ["assignDeleteUserError", "displayDeleteErrorMessage"], + }, + confirmUserSuspension: { + on: { + CONFIRM_USER_SUSPENSION: { + target: "suspendingUser", + }, + CANCEL_USER_SUSPENSION: { + target: "idle", + }, }, - ], - }, - }, - activatingUser: { - entry: "clearActivateUserError", - invoke: { - src: "activateUser", - id: "activateUser", - onDone: [ - { - target: "gettingUsers", - actions: "displayActivateSuccess", + }, + confirmUserDeletion: { + on: { + CONFIRM_USER_DELETE: { + target: "deletingUser", + }, + CANCEL_USER_DELETE: { + target: "idle", + }, }, - ], - onError: [ - { - target: "idle", - actions: [ - "assignActivateUserError", - "displayActivatedErrorMessage", - ], + }, + confirmUserActivation: { + on: { + CONFIRM_USER_ACTIVATION: { + target: "activatingUser", + }, + CANCEL_USER_ACTIVATION: { + target: "idle", + }, }, - ], - }, - }, - confirmUserPasswordReset: { - on: { - CONFIRM_USER_PASSWORD_RESET: { - target: "resettingUserPassword", }, - CANCEL_USER_PASSWORD_RESET: { - target: "idle", + suspendingUser: { + entry: "clearSuspendUserError", + invoke: { + src: "suspendUser", + id: "suspendUser", + onDone: [ + { + target: "gettingUsers", + actions: "displaySuspendSuccess", + }, + ], + onError: [ + { + target: "idle", + actions: [ + "assignSuspendUserError", + "displaySuspendedErrorMessage", + ], + }, + ], + }, }, - }, - }, - resettingUserPassword: { - entry: "clearResetUserPasswordError", - invoke: { - src: "resetUserPassword", - id: "resetUserPassword", - onDone: [ - { - target: "idle", - actions: "displayResetPasswordSuccess", + deletingUser: { + entry: "clearDeleteUserError", + invoke: { + src: "deleteUser", + id: "deleteUser", + onDone: [ + { + target: "gettingUsers", + actions: "displayDeleteSuccess", + }, + ], + onError: [ + { + target: "idle", + actions: [ + "assignDeleteUserError", + "displayDeleteErrorMessage", + ], + }, + ], }, - ], - onError: [ - { - target: "idle", - actions: [ - "assignResetUserPasswordError", - "displayResetPasswordErrorMessage", + }, + activatingUser: { + entry: "clearActivateUserError", + invoke: { + src: "activateUser", + id: "activateUser", + onDone: [ + { + target: "gettingUsers", + actions: "displayActivateSuccess", + }, + ], + onError: [ + { + target: "idle", + actions: [ + "assignActivateUserError", + "displayActivatedErrorMessage", + ], + }, ], }, - ], - }, - }, - updatingUserRoles: { - entry: "clearUpdateUserRolesError", - invoke: { - src: "updateUserRoles", - id: "updateUserRoles", - onDone: [ - { - target: "idle", - actions: "updateUserRolesInTheList", + }, + confirmUserPasswordReset: { + on: { + CONFIRM_USER_PASSWORD_RESET: { + target: "resettingUserPassword", + }, + CANCEL_USER_PASSWORD_RESET: { + target: "idle", + }, }, - ], - onError: [ - { - target: "idle", - actions: [ - "assignUpdateRolesError", - "displayUpdateRolesErrorMessage", + }, + resettingUserPassword: { + entry: "clearResetUserPasswordError", + invoke: { + src: "resetUserPassword", + id: "resetUserPassword", + onDone: [ + { + target: "idle", + actions: "displayResetPasswordSuccess", + }, + ], + onError: [ + { + target: "idle", + actions: [ + "assignResetUserPasswordError", + "displayResetPasswordErrorMessage", + ], + }, ], }, - ], + }, + updatingUserRoles: { + entry: "clearUpdateUserRolesError", + invoke: { + src: "updateUserRoles", + id: "updateUserRoles", + onDone: [ + { + target: "idle", + actions: "updateUserRolesInTheList", + }, + ], + onError: [ + { + target: "idle", + actions: [ + "assignUpdateRolesError", + "displayUpdateRolesErrorMessage", + ], + }, + ], + }, + }, }, }, }, }, - }, -}, { services: { // Passing API.getUsers directly does not invoke the function properly @@ -462,13 +465,13 @@ export const usersMachine = users: (_, event) => event.data, }), assignCount: assign({ - count: (_, event) => event.data.count + count: (_, event) => event.data.count, }), assignGetCountError: assign({ - getCountError: (_, event) => event.data + getCountError: (_, event) => event.data, }), clearGetCountError: assign({ - getCountError: (_) => undefined + getCountError: (_) => undefined, }), assignFilter: assign({ filter: (_, event) => event.query, From 6f8ec23b17adb07f6005f214baab0fe71aaf8f3d Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Thu, 3 Nov 2022 20:04:08 +0000 Subject: [PATCH 08/16] Add to authorize.go --- coderd/coderdtest/authorize.go | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 516b96809a049..800befd6bd948 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -245,6 +245,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { // Endpoints that use the SQLQuery filter. "GET:/api/v2/workspaces/": {StatusCode: http.StatusOK, NoAuthorize: true}, "GET:/api/v2/workspaces/count": {StatusCode: http.StatusOK, NoAuthorize: true}, + "GET:/api/v2/users/count": {StatusCode: http.StatusOK, NoAuthorize: true}, } // Routes like proxy routes support all HTTP methods. A helper func to expand From 5b442b443238c23c495b8acec110ca2587235c58 Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 4 Nov 2022 15:09:56 +0000 Subject: [PATCH 09/16] copy user array into local variable --- coderd/database/databasefake/databasefake.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 1c2db5b1377fb..0045ef5ca0be9 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -461,7 +461,7 @@ func (q *fakeQuerier) GetFilteredUserCount(_ context.Context, params database.Ge q.mutex.RLock() defer q.mutex.RUnlock() - users := make([]database.User, len(q.users)) + users := append([]database.User{}, q.users...) if params.Deleted { tmp := make([]database.User, 0, len(users)) From 3929a0070d43ce16fe1c37775d323bc49761bea4 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Fri, 4 Nov 2022 18:05:25 +0000 Subject: [PATCH 10/16] Authorize route --- coderd/coderdtest/authorize.go | 1 - coderd/users.go | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 800befd6bd948..516b96809a049 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -245,7 +245,6 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { // Endpoints that use the SQLQuery filter. "GET:/api/v2/workspaces/": {StatusCode: http.StatusOK, NoAuthorize: true}, "GET:/api/v2/workspaces/count": {StatusCode: http.StatusOK, NoAuthorize: true}, - "GET:/api/v2/users/count": {StatusCode: http.StatusOK, NoAuthorize: true}, } // Routes like proxy routes support all HTTP methods. A helper func to expand diff --git a/coderd/users.go b/coderd/users.go index 51e9f6a24995f..64d1aefba1654 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -273,6 +273,11 @@ func (api *API) userCount(rw http.ResponseWriter, r *http.Request) { return } + if !api.Authorize(r, rbac.ActionRead, rbac.ResourceUser) { + httpapi.Forbidden(rw) + return + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserCountResponse{ Count: count, }) From 4f9eb6f13ec8278beafa84b3b9c252df31be83aa Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Fri, 4 Nov 2022 18:57:13 +0000 Subject: [PATCH 11/16] Log count error --- site/src/pages/UsersPage/UsersPage.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 22ef21f8d4277..4142f9b1df1e6 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -1,4 +1,5 @@ import { useActor, useMachine } from "@xstate/react" +import { getErrorDetail } from "api/errors" import { User } from "api/typesGenerated" import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog" import { getPaginationContext } from "components/PaginationWidget/utils" @@ -44,6 +45,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { const { users, getUsersError, + getCountError, usernameToDelete, usernameToSuspend, usernameToActivate, @@ -74,6 +76,10 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { } }, [canEditUsers, rolesSend]) + if (getCountError) { + console.error(getErrorDetail(getCountError)) + } + return ( <> From 7d8562cd1eb060c02f954dc306b14775921c578d Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Fri, 4 Nov 2022 22:14:11 +0000 Subject: [PATCH 12/16] Authorize better --- coderd/database/databasefake/databasefake.go | 14 +++++++++++++- coderd/database/modelqueries.go | 20 ++++++++++++++++++-- coderd/database/queries/users.sql | 5 ++++- coderd/users.go | 7 +------ coderd/workspaces.go | 2 +- 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 0045ef5ca0be9..eac3163c0ace2 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -457,7 +457,12 @@ func (q *fakeQuerier) GetActiveUserCount(_ context.Context) (int64, error) { return active, nil } -func (q *fakeQuerier) GetFilteredUserCount(_ context.Context, params database.GetFilteredUserCountParams) (int64, error) { +func (q *fakeQuerier) GetFilteredUserCount(ctx context.Context, arg database.GetFilteredUserCountParams) (int64, error) { + count, err := q.GetAuthorizedUserCount(ctx, arg, nil) + return count, err +} + +func (q *fakeQuerier) GetAuthorizedUserCount(_ context.Context, params database.GetFilteredUserCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -508,6 +513,13 @@ func (q *fakeQuerier) GetFilteredUserCount(_ context.Context, params database.Ge users = usersFilteredByRole } + for _, user := range q.workspaces { + // If the filter exists, ensure the object is authorized. + if authorizedFilter != nil && !authorizedFilter.Eval(user.RBACObject()) { + continue + } + } + return int64(len(users)), nil } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 56cd7ff074622..4f7e78e822580 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -169,8 +169,6 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa } func (q *sqlQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) { - // In order to properly use ORDER BY, OFFSET, and LIMIT, we need to inject the - // authorizedFilter between the end of the where clause and those statements. filter := strings.Replace(getWorkspaceCount, "-- @authorize_filter", fmt.Sprintf(" AND %s", authorizedFilter.SQLString(rbac.NoACLConfig())), 1) // The name comment is for metric tracking query := fmt.Sprintf("-- name: GetAuthorizedWorkspaceCount :one\n%s", filter) @@ -187,3 +185,21 @@ func (q *sqlQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWor err := row.Scan(&count) return count, err } + +type userQuerier interface { + GetAuthorizedUserCount(ctx context.Context, arg GetFilteredUserCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) +} + +func (q *sqlQuerier) GetAuthorizedUserCount(ctx context.Context, arg GetFilteredUserCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) { + filter := strings.Replace(getUserCount, "-- @authorize_filter", fmt.Sprintf(" AND %s", authorizedFilter.SQLString(rbac.NoACLConfig())), 1) + query := fmt.Sprintf("-- name: GetAuthorizedUserCount :one\n%s", filter) + row := q.db.QueryRowContext(ctx, query, + arg.Deleted, + arg.Status, + arg.Search, + arg.RbacRole, + ) + var count int64 + err := row.Scan(&count) + return count, err +} diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 2937422d3d93b..a0560022c3776 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -69,7 +69,10 @@ WHERE WHEN cardinality(@rbac_role :: text[]) > 0 AND 'member' != ANY(@rbac_role :: text[]) THEN rbac_roles && @rbac_role :: text[] ELSE true - END; + END + -- Authorize Filter clause will be injected below in GetAuthorizedUserCount + -- @authorize_filter +; -- name: InsertUser :one INSERT INTO diff --git a/coderd/users.go b/coderd/users.go index 64d1aefba1654..d58841d33860c 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -263,7 +263,7 @@ func (api *API) userCount(rw http.ResponseWriter, r *http.Request) { return } - count, err := api.Database.GetFilteredUserCount(ctx, database.GetFilteredUserCountParams{ + count, err := api.Database.GetAuthorizedUserCount(ctx, database.GetFilteredUserCountParams{ Search: params.Search, Status: params.Status, RbacRole: params.RbacRole, @@ -273,11 +273,6 @@ func (api *API) userCount(rw http.ResponseWriter, r *http.Request) { return } - if !api.Authorize(r, rbac.ActionRead, rbac.ResourceUser) { - httpapi.Forbidden(rw) - return - } - httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserCountResponse{ Count: count, }) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 2b3d077d106ac..58c9f877dfd73 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -165,7 +165,7 @@ func (api *API) workspaceCount(rw http.ResponseWriter, r *http.Request) { filter, errs := workspaceSearchQuery(queryStr, codersdk.Pagination{}) if len(errs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid audit search query.", + Message: "Invalid workspace search query.", Validations: errs, }) return From b230df1730b1a7c2a5ae0527b440e10c891a0202 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Fri, 4 Nov 2022 22:20:28 +0000 Subject: [PATCH 13/16] Tweaks to authorization --- coderd/coderdtest/authorize.go | 1 + coderd/database/modelqueries.go | 1 + 2 files changed, 2 insertions(+) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 516b96809a049..800befd6bd948 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -245,6 +245,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { // Endpoints that use the SQLQuery filter. "GET:/api/v2/workspaces/": {StatusCode: http.StatusOK, NoAuthorize: true}, "GET:/api/v2/workspaces/count": {StatusCode: http.StatusOK, NoAuthorize: true}, + "GET:/api/v2/users/count": {StatusCode: http.StatusOK, NoAuthorize: true}, } // Routes like proxy routes support all HTTP methods. A helper func to expand diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 4f7e78e822580..c3e2c13da53b8 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -19,6 +19,7 @@ import ( type customQuerier interface { templateQuerier workspaceQuerier + userQuerier } type templateQuerier interface { From ca4a82f3d963b364f95992fbd15656da518d1734 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Fri, 4 Nov 2022 22:23:21 +0000 Subject: [PATCH 14/16] More authorization tweaks --- coderd/users.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/coderd/users.go b/coderd/users.go index d58841d33860c..91a2512b6ebc9 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -263,11 +263,20 @@ func (api *API) userCount(rw http.ResponseWriter, r *http.Request) { return } + sqlFilter, err := api.HTTPAuth.AuthorizeSQLFilter(r, rbac.ActionRead, rbac.ResourceUser.Type) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error preparing sql filter.", + Detail: err.Error(), + }) + return + } + count, err := api.Database.GetAuthorizedUserCount(ctx, database.GetFilteredUserCountParams{ Search: params.Search, Status: params.Status, RbacRole: params.RbacRole, - }) + }, sqlFilter) if err != nil { httpapi.InternalServerError(rw, err) return From 998725b27145e7bbed3d626ca7a2d9e48762a3e3 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Mon, 7 Nov 2022 19:34:18 +0000 Subject: [PATCH 15/16] Make gen --- coderd/database/queries.sql.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index af86efa83caf4..cf0dcd65d44e5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3979,6 +3979,8 @@ WHERE THEN rbac_roles && $4 :: text[] ELSE true END + -- Authorize Filter clause will be injected below in GetAuthorizedUserCount + -- @authorize_filter ` type GetFilteredUserCountParams struct { From 4210e284cf7413edeccb52f4c04560eaa83ac6d7 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Mon, 7 Nov 2022 21:40:51 +0000 Subject: [PATCH 16/16] Fix test --- coderd/database/modelqueries.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index c3e2c13da53b8..968b8b7e5cb90 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -192,13 +192,13 @@ type userQuerier interface { } func (q *sqlQuerier) GetAuthorizedUserCount(ctx context.Context, arg GetFilteredUserCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) { - filter := strings.Replace(getUserCount, "-- @authorize_filter", fmt.Sprintf(" AND %s", authorizedFilter.SQLString(rbac.NoACLConfig())), 1) + filter := strings.Replace(getFilteredUserCount, "-- @authorize_filter", fmt.Sprintf(" AND %s", authorizedFilter.SQLString(rbac.NoACLConfig())), 1) query := fmt.Sprintf("-- name: GetAuthorizedUserCount :one\n%s", filter) row := q.db.QueryRowContext(ctx, query, arg.Deleted, - arg.Status, arg.Search, - arg.RbacRole, + pq.Array(arg.Status), + pq.Array(arg.RbacRole), ) var count int64 err := row.Scan(&count)