feat: add shared_with_group: and shared_with_user: filters to /workspaces endpoint (#19875)

Adds shared_with_user and shared_with_group filters to the /workspaces
endpoint.

- `shared_with_user`: filters workspaces shared with a specific user.
Accepts a user UUID or username.
- `shared_with_group`: filters workspaces shared with a specific group.
Accepts:
  - a group UUID, or
  - `<organization name>/<group name>`, or
  - `<group name>` (resolved in the default organization).


Closes
[coder/internal#1004](https://github.com/coder/internal/issues/1004)
This commit is contained in:
Brett Kolodny
2025-09-19 16:05:27 -04:00
committed by GitHub
parent 40ffb79057
commit 38ca98745b
8 changed files with 407 additions and 9 deletions
+2
View File
@@ -276,6 +276,8 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
arg.HasAITask,
arg.HasExternalAgent,
arg.Shared,
arg.SharedWithUserID,
arg.SharedWithGroupID,
arg.RequesterID,
arg.Offset,
arg.Limit,
+21 -6
View File
@@ -21113,7 +21113,18 @@ WHERE
(workspaces.user_acl != '{}'::jsonb OR workspaces.group_acl != '{}'::jsonb) = $21 :: boolean
ELSE true
END
-- Filter by shared_with_user_id
AND CASE
WHEN $22 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspaces.user_acl ? ($22 :: uuid) :: text
ELSE true
END
-- Filter by shared_with_group_id
AND CASE
WHEN $23 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspaces.group_acl ? ($23 :: uuid) :: text
ELSE true
END
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
-- @authorize_filter
), filtered_workspaces_order AS (
@@ -21123,7 +21134,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 = $22 AND favorite THEN 0 ELSE 1 END ASC,
CASE WHEN owner_id = $24 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
@@ -21132,11 +21143,11 @@ WHERE
LOWER(name) ASC
LIMIT
CASE
WHEN $24 :: integer > 0 THEN
$24
WHEN $26 :: integer > 0 THEN
$26
END
OFFSET
$23
$25
), 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
@@ -21186,7 +21197,7 @@ WHERE
false, -- latest_build_has_ai_task
false -- latest_build_has_external_agent
WHERE
$25 :: boolean = true
$27 :: boolean = true
), total_count AS (
SELECT
count(*) AS count
@@ -21224,6 +21235,8 @@ type GetWorkspacesParams struct {
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"`
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"`
RequesterID uuid.UUID `db:"requester_id" json:"requester_id"`
Offset int32 `db:"offset_" json:"offset_"`
Limit int32 `db:"limit_" json:"limit_"`
@@ -21298,6 +21311,8 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
arg.HasAITask,
arg.HasExternalAgent,
arg.Shared,
arg.SharedWithUserID,
arg.SharedWithGroupID,
arg.RequesterID,
arg.Offset,
arg.Limit,
+12 -1
View File
@@ -384,7 +384,18 @@ WHERE
(workspaces.user_acl != '{}'::jsonb OR workspaces.group_acl != '{}'::jsonb) = sqlc.narg('shared') :: boolean
ELSE true
END
-- Filter by shared_with_user_id
AND CASE
WHEN @shared_with_user_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspaces.user_acl ? (@shared_with_user_id :: uuid) :: text
ELSE true
END
-- Filter by shared_with_group_id
AND CASE
WHEN @shared_with_group_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspaces.group_acl ? (@shared_with_group_id :: uuid) :: text
ELSE true
END
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
-- @authorize_filter
), filtered_workspaces_order AS (
+81
View File
@@ -226,6 +226,8 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder
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")
filter.SharedWithUserID = parseUser(ctx, db, parser, values, "shared_with_user")
filter.SharedWithGroupID = parseGroup(ctx, db, parser, values, "shared_with_group")
type paramMatch struct {
name string
@@ -363,6 +365,85 @@ func parseOrganization(ctx context.Context, db database.Store, parser *httpapi.Q
})
}
func parseUser(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string) uuid.UUID {
return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) {
if v == "" {
return uuid.Nil, nil
}
userID, err := uuid.Parse(v)
if err == nil {
return userID, nil
}
user, err := db.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
Username: v,
})
if err != nil {
return uuid.Nil, xerrors.Errorf("user %q either does not exist, or you are unauthorized to view them", v)
}
return user.ID, nil
})
}
// Parse a group filter value into a group UUID.
// Supported formats:
// - <group-uuid>
// - <organization-name>/<group-name>
// - <group-name> (resolved in the default organization)
func parseGroup(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string) uuid.UUID {
return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) {
if v == "" {
return uuid.Nil, nil
}
groupID, err := uuid.Parse(v)
if err == nil {
return groupID, nil
}
var groupName string
var org database.Organization
parts := strings.Split(v, "/")
switch len(parts) {
case 1:
dbOrg, err := db.GetDefaultOrganization(ctx)
if err != nil {
return uuid.Nil, xerrors.New("fetching default organization")
}
org = dbOrg
groupName = parts[0]
case 2:
orgName := parts[0]
if err := codersdk.NameValid(orgName); err != nil {
return uuid.Nil, xerrors.Errorf("invalid organization name %w", err)
}
dbOrg, err := db.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{
Name: orgName,
})
if err != nil {
return uuid.Nil, xerrors.Errorf("organization %q either does not exist, or you are unauthorized to view it", orgName)
}
org = dbOrg
groupName = parts[1]
default:
return uuid.Nil, xerrors.New("invalid organization or group name, the filter must be in the pattern of <organization name>/<group name>")
}
if err := codersdk.GroupNameValid(groupName); err != nil {
return uuid.Nil, xerrors.Errorf("invalid group name %w", err)
}
group, err := db.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{
OrganizationID: org.ID,
Name: groupName,
})
if err != nil {
return uuid.Nil, xerrors.Errorf("group %q either does not exist, does not belong to the organization %q, or you are unauthorized to view it", groupName, org.Name)
}
return group.ID, nil
})
}
// splitQueryParameterByDelimiter takes a query string and splits it into the individual elements
// of the query. Each element is separated by a delimiter. All quoted strings are
// kept as a single element.
+93
View File
@@ -312,6 +312,84 @@ func TestSearchWorkspace(t *testing.T) {
},
},
},
{
Name: "SharedWithUser",
Query: `shared_with_user:3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf`,
Setup: func(t *testing.T, db database.Store) {
dbgen.User(t, db, database.User{
ID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"),
})
},
Expected: database.GetWorkspacesParams{
SharedWithUserID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"),
},
},
{
Name: "SharedWithUserByName",
Query: `shared_with_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{
SharedWithUserID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"),
},
},
{
Name: "SharedWithGroupDefaultOrg",
Query: "shared_with_group:wibble",
Setup: func(t *testing.T, db database.Store) {
org, err := db.GetOrganizationByName(t.Context(), database.GetOrganizationByNameParams{
Name: "coder",
})
require.NoError(t, err)
dbgen.Group(t, db, database.Group{
ID: uuid.MustParse("590f1006-15e6-4b21-a6e1-92e33af8a5c3"),
Name: "wibble",
OrganizationID: org.ID,
})
},
Expected: database.GetWorkspacesParams{
SharedWithGroupID: uuid.MustParse("590f1006-15e6-4b21-a6e1-92e33af8a5c3"),
},
},
{
Name: "SharedWithGroupInOrg",
Query: "shared_with_group:wibble/wobble",
Setup: func(t *testing.T, db database.Store) {
org := dbgen.Organization(t, db, database.Organization{
ID: uuid.MustParse("dbeb1bd5-dce6-459c-ab7b-b7f8b9b10467"),
Name: "wibble",
})
dbgen.Group(t, db, database.Group{
ID: uuid.MustParse("3c831688-0a5a-45a2-a796-f7648874df34"),
Name: "wobble",
OrganizationID: org.ID,
})
},
Expected: database.GetWorkspacesParams{
SharedWithGroupID: uuid.MustParse("3c831688-0a5a-45a2-a796-f7648874df34"),
},
},
{
Name: "SharedWithGroupID",
Query: "shared_with_group:a7d1ba00-53c7-4aa6-92ea-83157dd57480",
Setup: func(t *testing.T, db database.Store) {
org := dbgen.Organization(t, db, database.Organization{
ID: uuid.MustParse("8606620f-fee4-4c4e-83ba-f42db804139a"),
})
dbgen.Group(t, db, database.Group{
ID: uuid.MustParse("a7d1ba00-53c7-4aa6-92ea-83157dd57480"),
OrganizationID: org.ID,
})
},
Expected: database.GetWorkspacesParams{
SharedWithGroupID: uuid.MustParse("a7d1ba00-53c7-4aa6-92ea-83157dd57480"),
},
},
// Failures
{
@@ -354,6 +432,21 @@ func TestSearchWorkspace(t *testing.T) {
Query: "param:foo:value",
ExpectedErrorContains: "can only contain 1 ':'",
},
{
Name: "SharedWithGroupTooManySegments",
Query: `shared_with_group:acme/devs/extra`,
ExpectedErrorContains: "the filter must be in the pattern of <organization name>/<group name>",
},
{
Name: "SharedWithGroupEmptyOrg",
Query: `shared_with_group:/devs`,
ExpectedErrorContains: "invalid organization name",
},
{
Name: "SharedWithGroupEmptyGroup",
Query: `shared_with_group:acme/`,
ExpectedErrorContains: "organization \"acme\" either does not exist",
},
}
for _, c := range testCases {
+78 -2
View File
@@ -1813,7 +1813,7 @@ func TestWorkspaceFilter(t *testing.T) {
})
}
t.Run("SharedWithUser", func(t *testing.T) {
t.Run("Shared", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
@@ -1851,7 +1851,7 @@ func TestWorkspaceFilter(t *testing.T) {
require.Equal(t, workspaces.Workspaces[0].ID, sharedWorkspace.ID)
})
t.Run("NotSharedWithUser", func(t *testing.T) {
t.Run("NotShared", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
@@ -1888,6 +1888,82 @@ func TestWorkspaceFilter(t *testing.T) {
require.Equal(t, 1, workspaces.Count, "expected only one workspace")
require.Equal(t, workspaces.Workspaces[0].ID, notSharedWorkspace.ID)
})
t.Run("SharedWithUserByID", 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{
SharedWithUser: toShareWithUser.ID.String(),
})
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("SharedWithUserByUsername", 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{
SharedWithUser: toShareWithUser.Username,
})
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)
})
}
// TestWorkspaceFilterManual runs some specific setups with basic checks.
+10
View File
@@ -518,6 +518,10 @@ type WorkspaceFilter struct {
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:"-"`
// SharedWithUser is the username or ID of the user that the workspace is shared with
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:"-"`
// FilterQuery supports a raw filter query string
FilterQuery string `json:"q,omitempty"`
}
@@ -544,6 +548,12 @@ func (f WorkspaceFilter) asRequestOption() RequestOption {
if f.Shared != nil {
params = append(params, fmt.Sprintf("shared:%v", *f.Shared))
}
if f.SharedWithUser != "" {
params = append(params, fmt.Sprintf("shared_with_user:%q", f.SharedWithUser))
}
if f.SharedWithGroup != "" {
params = append(params, fmt.Sprintf("shared_with_group:%q", f.SharedWithGroup))
}
if f.FilterQuery != "" {
// If custom stuff is added, just add it on here.
params = append(params, f.FilterQuery)
+110
View File
@@ -3723,6 +3723,116 @@ func TestWorkspacesFiltering(t *testing.T) {
require.Equal(t, 1, workspaces.Count, "expected only one workspace")
require.Equal(t, workspaces.Workspaces[0].ID, notSharedWorkspace.ID)
})
t.Run("SharedWithGroupByID", 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")
err = client.UpdateWorkspaceACL(ctx, sharedWorkspace.ID, codersdk.UpdateWorkspaceACL{
GroupRoles: map[string]codersdk.WorkspaceRole{
group.ID.String(): codersdk.WorkspaceRoleUse,
},
})
require.NoError(t, err)
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
SharedWithGroup: group.ID.String(),
})
require.NoError(t, err)
require.Equal(t, 1, workspaces.Count)
require.Equal(t, sharedWorkspace.ID, workspaces.Workspaces[0].ID)
})
t.Run("SharedWithGroupFilter", 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")
err = client.UpdateWorkspaceACL(ctx, sharedWorkspace.ID, codersdk.UpdateWorkspaceACL{
GroupRoles: map[string]codersdk.WorkspaceRole{
group.ID.String(): codersdk.WorkspaceRoleUse,
},
})
require.NoError(t, err)
workspacesByID, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
SharedWithGroup: group.ID.String(),
})
require.NoError(t, err)
require.Equal(t, 1, workspacesByID.Count)
require.Equal(t, sharedWorkspace.ID, workspacesByID.Workspaces[0].ID)
workspacesByName, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
SharedWithGroup: group.Name,
})
require.NoError(t, err)
require.Equal(t, 1, workspacesByName.Count)
require.Equal(t, sharedWorkspace.ID, workspacesByName.Workspaces[0].ID)
workspacesByOrgAndName, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
SharedWithGroup: fmt.Sprintf("coder/%s", group.Name),
})
require.NoError(t, err)
require.Equal(t, 1, workspacesByOrgAndName.Count)
require.Equal(t, sharedWorkspace.ID, workspacesByOrgAndName.Workspaces[0].ID)
})
}
// TestWorkspacesWithoutTemplatePerms creates a workspace for a user, then drops