feat: add shared filter to workspaces query (#19807)

Adds a `shared:<boolean>` search query to the `/workspaces [get]`
endpoint


https://github.com/user-attachments/assets/ccf84bd9-c1fd-4085-825b-2e3176a2d488

Closes
[coder/internal#972](https://github.com/coder/internal/issues/972)
This commit is contained in:
Brett Kolodny
2025-09-16 12:37:39 -04:00
committed by GitHub
parent 8487216548
commit e6b04d1918
8 changed files with 282 additions and 5 deletions
+1
View File
@@ -275,6 +275,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
arg.UsingActive,
arg.HasAITask,
arg.HasExternalAgent,
arg.Shared,
arg.RequesterID,
arg.Offset,
arg.Limit,
+14 -5
View File
@@ -20960,6 +20960,13 @@ WHERE
latest_build.has_external_agent = $20 :: boolean
ELSE true
END
-- Filter by shared status
AND CASE
WHEN $21 :: boolean IS NOT NULL THEN
(workspaces.user_acl != '{}'::jsonb OR workspaces.group_acl != '{}'::jsonb) = $21 :: boolean
ELSE true
END
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
-- @authorize_filter
), filtered_workspaces_order AS (
@@ -20969,7 +20976,7 @@ WHERE
filtered_workspaces fw
ORDER BY
-- To ensure that 'favorite' workspaces show up first in the list only for their owner.
CASE WHEN owner_id = $21 AND favorite THEN 0 ELSE 1 END ASC,
CASE WHEN owner_id = $22 AND favorite 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
@@ -20978,11 +20985,11 @@ WHERE
LOWER(name) ASC
LIMIT
CASE
WHEN $23 :: integer > 0 THEN
$23
WHEN $24 :: integer > 0 THEN
$24
END
OFFSET
$22
$23
), 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.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_ai_task, fwo.latest_build_has_external_agent
@@ -21032,7 +21039,7 @@ WHERE
false, -- latest_build_has_ai_task
false -- latest_build_has_external_agent
WHERE
$24 :: boolean = true
$25 :: boolean = true
), total_count AS (
SELECT
count(*) AS count
@@ -21069,6 +21076,7 @@ type GetWorkspacesParams struct {
UsingActive sql.NullBool `db:"using_active" json:"using_active"`
HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"`
HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"`
Shared sql.NullBool `db:"shared" json:"shared"`
RequesterID uuid.UUID `db:"requester_id" json:"requester_id"`
Offset int32 `db:"offset_" json:"offset_"`
Limit int32 `db:"limit_" json:"limit_"`
@@ -21142,6 +21150,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
arg.UsingActive,
arg.HasAITask,
arg.HasExternalAgent,
arg.Shared,
arg.RequesterID,
arg.Offset,
arg.Limit,
+7
View File
@@ -378,6 +378,13 @@ WHERE
latest_build.has_external_agent = sqlc.narg('has_external_agent') :: boolean
ELSE true
END
-- Filter by shared status
AND CASE
WHEN sqlc.narg('shared') :: boolean IS NOT NULL THEN
(workspaces.user_acl != '{}'::jsonb OR workspaces.group_acl != '{}'::jsonb) = sqlc.narg('shared') :: boolean
ELSE true
END
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
-- @authorize_filter
), filtered_workspaces_order AS (
+1
View File
@@ -225,6 +225,7 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder
filter.HasAITask = parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task")
filter.HasExternalAgent = parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent")
filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization")
filter.Shared = parser.NullableBoolean(values, sql.NullBool{}, "shared")
type paramMatch struct {
name string
+30
View File
@@ -282,6 +282,36 @@ func TestSearchWorkspace(t *testing.T) {
},
},
},
{
Name: "SharedTrue",
Query: "shared:true",
Expected: database.GetWorkspacesParams{
Shared: sql.NullBool{
Bool: true,
Valid: true,
},
},
},
{
Name: "SharedFalse",
Query: "shared:false",
Expected: database.GetWorkspacesParams{
Shared: sql.NullBool{
Bool: false,
Valid: true,
},
},
},
{
Name: "SharedMissing",
Query: "",
Expected: database.GetWorkspacesParams{
Shared: sql.NullBool{
Bool: false,
Valid: false,
},
},
},
// Failures
{
+76
View File
@@ -1812,6 +1812,82 @@ func TestWorkspaceFilter(t *testing.T) {
require.ElementsMatch(t, exp, workspaces, "expected workspaces returned")
})
}
t.Run("SharedWithUser", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)}
var (
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: dv,
})
orgOwner = coderdtest.CreateFirstUser(t, client)
_, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID))
sharedWorkspace = 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
_, toShareWithUser = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID)
ctx = testutil.Context(t, testutil.WaitMedium)
)
client.UpdateWorkspaceACL(ctx, sharedWorkspace.ID, codersdk.UpdateWorkspaceACL{
UserRoles: map[string]codersdk.WorkspaceRole{
toShareWithUser.ID.String(): codersdk.WorkspaceRoleUse,
},
})
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Shared: ptr.Ref(true),
})
require.NoError(t, err, "fetch workspaces")
require.Equal(t, 1, workspaces.Count, "expected only one workspace")
require.Equal(t, workspaces.Workspaces[0].ID, sharedWorkspace.ID)
})
t.Run("NotSharedWithUser", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)}
var (
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: dv,
})
orgOwner = coderdtest.CreateFirstUser(t, client)
_, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID))
sharedWorkspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OwnerID: workspaceOwner.ID,
OrganizationID: orgOwner.OrganizationID,
}).Do().Workspace
notSharedWorkspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OwnerID: workspaceOwner.ID,
OrganizationID: orgOwner.OrganizationID,
}).Do().Workspace
_, toShareWithUser = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID)
ctx = testutil.Context(t, testutil.WaitMedium)
)
client.UpdateWorkspaceACL(ctx, sharedWorkspace.ID, codersdk.UpdateWorkspaceACL{
UserRoles: map[string]codersdk.WorkspaceRole{
toShareWithUser.ID.String(): codersdk.WorkspaceRoleUse,
},
})
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Shared: ptr.Ref(false),
})
require.NoError(t, err, "fetch workspaces")
require.Equal(t, 1, workspaces.Count, "expected only one workspace")
require.Equal(t, workspaces.Workspaces[0].ID, notSharedWorkspace.ID)
})
}
// TestWorkspaceFilterManual runs some specific setups with basic checks.
+5
View File
@@ -516,6 +516,8 @@ type WorkspaceFilter struct {
Offset int `json:"offset,omitempty" typescript:"-"`
// Limit is a limit on the number of workspaces returned.
Limit int `json:"limit,omitempty" typescript:"-"`
// Shared is a whether the workspace is shared with any users or groups
Shared *bool `json:"shared,omitempty" typescript:"-"`
// FilterQuery supports a raw filter query string
FilterQuery string `json:"q,omitempty"`
}
@@ -539,6 +541,9 @@ func (f WorkspaceFilter) asRequestOption() RequestOption {
if f.Status != "" {
params = append(params, fmt.Sprintf("status:%q", f.Status))
}
if f.Shared != nil {
params = append(params, fmt.Sprintf("shared:%v", *f.Shared))
}
if f.FilterQuery != "" {
// If custom stuff is added, just add it on here.
params = append(params, f.FilterQuery)
+148
View File
@@ -3575,6 +3575,154 @@ func TestWorkspacesFiltering(t *testing.T) {
}
}
})
t.Run("SharedWithGroup", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)}
var (
client, db, orgOwner = coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
_, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID))
sharedWorkspace = 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)
)
group, err := client.CreateGroup(ctx, orgOwner.OrganizationID, codersdk.CreateGroupRequest{
Name: "wibble",
})
require.NoError(t, err, "create group")
client.UpdateWorkspaceACL(ctx, sharedWorkspace.ID, codersdk.UpdateWorkspaceACL{
GroupRoles: map[string]codersdk.WorkspaceRole{
group.ID.String(): codersdk.WorkspaceRoleUse,
},
})
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Shared: ptr.Ref(true),
})
require.NoError(t, err, "fetch workspaces")
require.Equal(t, 1, workspaces.Count, "expected only one workspace")
require.Equal(t, workspaces.Workspaces[0].ID, sharedWorkspace.ID)
})
t.Run("SharedWithUserAndGroup", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)}
var (
client, db, orgOwner = coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
_, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID))
sharedWorkspace = 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
_, toShareWithUser = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID)
ctx = testutil.Context(t, testutil.WaitMedium)
)
group, err := client.CreateGroup(ctx, orgOwner.OrganizationID, codersdk.CreateGroupRequest{
Name: "wibble",
})
require.NoError(t, err, "create group")
client.UpdateWorkspaceACL(ctx, sharedWorkspace.ID, codersdk.UpdateWorkspaceACL{
UserRoles: map[string]codersdk.WorkspaceRole{
toShareWithUser.ID.String(): codersdk.WorkspaceRoleUse,
},
GroupRoles: map[string]codersdk.WorkspaceRole{
group.ID.String(): codersdk.WorkspaceRoleUse,
},
})
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Shared: ptr.Ref(true),
})
require.NoError(t, err, "fetch workspaces")
require.Equal(t, 1, workspaces.Count, "expected only one workspace")
require.Equal(t, workspaces.Workspaces[0].ID, sharedWorkspace.ID)
})
t.Run("NotSharedWithGroup", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)}
var (
client, db, orgOwner = coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
_, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID))
sharedWorkspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OwnerID: workspaceOwner.ID,
OrganizationID: orgOwner.OrganizationID,
}).Do().Workspace
notSharedWorkspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OwnerID: workspaceOwner.ID,
OrganizationID: orgOwner.OrganizationID,
}).Do().Workspace
ctx = testutil.Context(t, testutil.WaitMedium)
)
group, err := client.CreateGroup(ctx, orgOwner.OrganizationID, codersdk.CreateGroupRequest{
Name: "wibble",
})
require.NoError(t, err, "create group")
client.UpdateWorkspaceACL(ctx, sharedWorkspace.ID, codersdk.UpdateWorkspaceACL{
GroupRoles: map[string]codersdk.WorkspaceRole{
group.ID.String(): codersdk.WorkspaceRoleUse,
},
})
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Shared: ptr.Ref(false),
})
require.NoError(t, err, "fetch workspaces")
require.Equal(t, 1, workspaces.Count, "expected only one workspace")
require.Equal(t, workspaces.Workspaces[0].ID, notSharedWorkspace.ID)
})
}
// TestWorkspacesWithoutTemplatePerms creates a workspace for a user, then drops