diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index f9db353a0d..befe7b9c82 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -344,6 +344,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.Shared, arg.SharedWithUserID, arg.SharedWithGroupID, + arg.UserID, arg.RequesterID, arg.Offset, arg.Limit, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5a2a0a939e..66728f50e4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -39735,6 +39735,20 @@ WHERE workspaces.group_acl ? ($23 :: uuid) :: text ELSE true END + -- Filter by user_id: workspaces the user owns, or that are shared with + -- them directly or through a group they belong to. + AND CASE + WHEN $24 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + workspaces.owner_id = $24 + OR workspaces.user_acl ? ($24 :: uuid) :: text + OR EXISTS ( + SELECT 1 + FROM group_members_expanded + WHERE group_members_expanded.user_id = $24 + AND workspaces.group_acl ? group_members_expanded.group_id :: text + ) + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter @@ -39745,7 +39759,7 @@ WHERE filtered_workspaces fw ORDER BY -- To ensure that 'favorite' workspaces show up first in the list only for their owner. - CASE WHEN favorite AND owner_username = (SELECT users.username FROM users WHERE users.id = $24) THEN 0 ELSE 1 END ASC, + CASE WHEN favorite AND owner_username = (SELECT users.username FROM users WHERE users.id = $25) THEN 0 ELSE 1 END ASC, (latest_build_completed_at IS NOT NULL AND latest_build_canceled_at IS NULL AND latest_build_error IS NULL AND @@ -39754,11 +39768,11 @@ WHERE LOWER(name) ASC LIMIT CASE - WHEN $26 :: integer > 0 THEN - $26 + WHEN $27 :: integer > 0 THEN + $27 END OFFSET - $25 + $26 ), filtered_workspaces_order_with_summary AS ( SELECT fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.group_acl_display_info, fwo.user_acl_display_info, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_external_agent, fwo.latest_build_provisioner_job_id @@ -39810,7 +39824,7 @@ WHERE false, -- latest_build_has_external_agent '00000000-0000-0000-0000-000000000000'::uuid -- latest_build_provisioner_job_id WHERE - $27 :: boolean = true + $28 :: boolean = true ), total_count AS ( SELECT count(*) AS count @@ -39903,6 +39917,7 @@ type GetWorkspacesParams struct { Shared sql.NullBool `db:"shared" json:"shared"` SharedWithUserID uuid.UUID `db:"shared_with_user_id" json:"shared_with_user_id"` SharedWithGroupID uuid.UUID `db:"shared_with_group_id" json:"shared_with_group_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` RequesterID uuid.UUID `db:"requester_id" json:"requester_id"` Offset int32 `db:"offset_" json:"offset_"` Limit int32 `db:"limit_" json:"limit_"` @@ -39982,6 +39997,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.Shared, arg.SharedWithUserID, arg.SharedWithGroupID, + arg.UserID, arg.RequesterID, arg.Offset, arg.Limit, diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 090e00ad2d..c72e14bd5b 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -374,6 +374,20 @@ WHERE workspaces.group_acl ? (@shared_with_group_id :: uuid) :: text ELSE true END + -- Filter by user_id: workspaces the user owns, or that are shared with + -- them directly or through a group they belong to. + AND CASE + WHEN @user_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + workspaces.owner_id = @user_id + OR workspaces.user_acl ? (@user_id :: uuid) :: text + OR EXISTS ( + SELECT 1 + FROM group_members_expanded + WHERE group_members_expanded.user_id = @user_id + AND workspaces.group_acl ? group_members_expanded.group_id :: text + ) + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index f258124099..4f7e62f260 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -304,6 +304,7 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder filter.Shared = parser.NullableBoolean(values, sql.NullBool{}, "shared") filter.SharedWithUserID = parseUser(ctx, db, parser, values, "shared_with_user", actorID) filter.SharedWithGroupID = parseGroup(ctx, db, parser, values, "shared_with_group") + filter.UserID = parseUser(ctx, db, parser, values, "user", actorID) // Translate healthy filter to has-agent statuses // healthy:true = connected, healthy:false = disconnected or timeout if healthy := parser.NullableBoolean(values, sql.NullBool{}, "healthy"); healthy.Valid { diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 1b4e0fc029..425303fe3c 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -356,6 +356,32 @@ func TestSearchWorkspace(t *testing.T) { SharedWithUserID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"), }, }, + { + Name: "UserMe", + Query: `user:me`, + Setup: func(t *testing.T, db database.Store) { + dbgen.User(t, db, database.User{ + ID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"), + }) + }, + ActorID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"), + Expected: database.GetWorkspacesParams{ + UserID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"), + }, + }, + { + Name: "UserByName", + Query: `user:wibble`, + Setup: func(t *testing.T, db database.Store) { + dbgen.User(t, db, database.User{ + ID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"), + Username: "wibble", + }) + }, + Expected: database.GetWorkspacesParams{ + UserID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"), + }, + }, { Name: "SharedWithGroupDefaultOrg", Query: "shared_with_group:wibble", diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index fbbe42e85a..0aea99aadc 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -2970,6 +2970,73 @@ func TestWorkspaceFilterManual(t *testing.T) { expectIDs(t, []codersdk.Workspace{foo, bar, baz}, all.Workspaces) }) }) + + // The user filter matches workspaces the user owns, plus workspaces + // shared with them directly or through a group they belong to. + t.Run("User", func(t *testing.T) { + t.Parallel() + + var ( + client, db = coderdtest.NewWithDatabase(t, nil) + orgOwner = coderdtest.CreateFirstUser(t, client) + _, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID) + userClient, user = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID) + group = dbgen.Group(t, db, database.Group{OrganizationID: orgOwner.OrganizationID}) + ownedWorkspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + userSharedWorkspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + groupSharedWorkspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + _ = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + ctx = testutil.Context(t, testutil.WaitMedium) + ) + + dbgen.GroupMember(t, db, database.GroupMemberTable{ + GroupID: group.ID, + UserID: user.ID, + }) + + err := client.UpdateWorkspaceACL(ctx, userSharedWorkspace.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + user.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + require.NoError(t, err) + err = client.UpdateWorkspaceACL(ctx, groupSharedWorkspace.ID, codersdk.UpdateWorkspaceACL{ + GroupRoles: map[string]codersdk.WorkspaceRole{ + group.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + require.NoError(t, err) + + expected := []codersdk.Workspace{ + {ID: ownedWorkspace.ID}, + {ID: userSharedWorkspace.ID}, + {ID: groupSharedWorkspace.ID}, + } + + byUsername, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + User: user.Username, + }) + require.NoError(t, err, "fetch workspaces by username") + expectIDs(t, expected, byUsername.Workspaces) + + asMe, err := userClient.Workspaces(ctx, codersdk.WorkspaceFilter{ + User: codersdk.Me, + }) + require.NoError(t, err, "fetch workspaces as me") + expectIDs(t, expected, asMe.Workspaces) + }) } func TestOffsetLimit(t *testing.T) { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 5d7a5c68ab..faf405db91 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -559,6 +559,9 @@ type WorkspaceFilter struct { SharedWithUser string `json:"shared_with_user,omitempty" typescript:"-"` // SharedWithGroup is the group name, group ID, or / of the group that the workspace is shared with SharedWithGroup string `json:"shared_with_group,omitempty" typescript:"-"` + // User is "me", a username, or a user ID. It matches workspaces the user + // owns or that are shared with them directly or through a group. + User string `json:"user,omitempty" typescript:"-"` // IncludeAgentMetadata expands each agent in the response with the // named metadata keys. It does not filter the returned workspaces. IncludeAgentMetadata []string `json:"include_agent_metadata,omitempty" typescript:"-"` @@ -597,6 +600,9 @@ func (f WorkspaceFilter) asRequestOption() RequestOption { if f.SharedWithGroup != "" { params = append(params, fmt.Sprintf("shared_with_group:%q", f.SharedWithGroup)) } + if f.User != "" { + params = append(params, fmt.Sprintf("user:%q", f.User)) + } for _, key := range f.IncludeAgentMetadata { params = append(params, fmt.Sprintf("include_agent_metadata:%q", key)) } diff --git a/docs/user-guides/shared-workspaces.md b/docs/user-guides/shared-workspaces.md index 59fe42d87c..70e97ec43d 100644 --- a/docs/user-guides/shared-workspaces.md +++ b/docs/user-guides/shared-workspaces.md @@ -56,6 +56,11 @@ To list shared workspaces: - `coder list --search shared_with_user:` - `coder list --search shared_with_group:` +To list the workspaces you own together with the ones shared with you, +directly or through a group: + +- `coder list --search user:me` + ### UI #### Sharing your Workspace diff --git a/docs/user-guides/workspace-management.md b/docs/user-guides/workspace-management.md index c6df993cd0..175f636d30 100644 --- a/docs/user-guides/workspace-management.md +++ b/docs/user-guides/workspace-management.md @@ -61,6 +61,9 @@ The following filters are supported: - `owner` - Represents the `username` of the owner. You can also use `me` as a convenient alias for the logged-in user, e.g., `owner:me` +- `user` - Workspaces a user owns, plus workspaces shared with them directly + or through a group they belong to. Accepts a `username`, user ID, or `me`, + e.g., `user:me` - `name` - Name of the workspace. - `template` - Name of the template. - `status` - Indicates the status of the workspace, e.g, `status:failed` For a