feat: add user workspace search filter (#29442)

This commit is contained in:
McKayla はな
2026-09-18 12:57:21 -06:00
committed by GitHub
parent 057475c4e8
commit 66a433985e
9 changed files with 144 additions and 5 deletions
+1
View File
@@ -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,
+21 -5
View File
@@ -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,
+14
View File
@@ -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
+1
View File
@@ -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 {
+26
View File
@@ -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",
+67
View File
@@ -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) {
+6
View File
@@ -559,6 +559,9 @@ type WorkspaceFilter struct {
SharedWithUser string `json:"shared_with_user,omitempty" typescript:"-"`
// SharedWithGroup is the group name, group ID, or <org name>/<group name> 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))
}
+5
View File
@@ -56,6 +56,11 @@ To list shared workspaces:
- `coder list --search shared_with_user:<user>`
- `coder list --search shared_with_group:<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
+3
View File
@@ -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