diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index b558bba91e..49c13e1365 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -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, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ebff2c5453..0d8ab2e021 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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, diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index a4a3200f5c..9190c263c5 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -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 ( diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 0ab700fbee..5e7644d99b 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -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 diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 5c52e15851..60e97c6d63 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -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 { diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index abf3941e61..98cfba017a 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -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. diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index a006595f0e..32ec26643c 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -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) diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index e251eaecfa..35214c4bf3 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -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