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
+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 {