Files
coder/coderd/searchquery/search.go
T
Kyle Carberry b779c9ee33 fix: use SQL-level auth filtering for chat listing (#23159)
## Problem

The chat listing endpoint (`GetChatsByOwnerID`) was using
`fetchWithPostFilter`, which fetches N rows from the database and then
filters them in Go memory using RBAC checks. This causes a pagination
bug: if the user requests `limit=25` but some rows fail the auth check,
fewer than 25 rows are returned even though more authorized rows exist
in the database. The client may incorrectly assume it has reached the
end of the list.

## Solution

Switch to the same pattern used by `GetWorkspaces`, `GetTemplates`, and
`GetUsers`: `prepareSQLFilter` + `GetAuthorized*` variant. The RBAC
filter is compiled to a SQL WHERE clause and injected into the query
before `ORDER BY`/`LIMIT`, so the database returns exactly the requested
number of authorized rows.

Additionally, `GetChatsByOwnerID` is renamed to `GetChats` with
`OwnerID` as an optional (nullable) filter parameter, matching the
`GetWorkspaces` naming convention.

## Changes

| File | Change |
|------|--------|
| `queries/chats.sql` | Renamed to `GetChats`, `owner_id` now optional
via CASE/NULL, added `-- @authorize_filter` |
| `queries.sql.go` | Renamed constant, params struct (`GetChatsParams`),
and method |
| `querier.go` | Interface method renamed |
| `modelqueries.go` | Added `chatQuerier` interface +
`GetAuthorizedChats` impl |
| `dbauthz/dbauthz.go` | `GetChats` now uses `prepareSQLFilter` instead
of `fetchWithPostFilter` |
| `dbauthz/dbauthz_test.go` | Updated tests for SQL filter pattern |
| `dbmock/dbmock.go` | Renamed + added mock for `GetAuthorizedChats` |
| `dbmetrics/querymetrics.go` | Renamed + added metrics wrapper |
| `rbac/regosql/configs.go` | Added `ChatConverter` (maps `org_owner` to
empty string literal since `chats` has no `organization_id` column) |
| `rbac/authz.go` | Added `ConfigChats()` |
| `chats.go` | Handler uses renamed method with `uuid.NullUUID` |
| `searchquery/search.go` | Updated return type |
| `gitsync/worker.go` | Updated interface and call site |
| Various test files | Updated for renamed types |
2026-03-17 12:46:24 -04:00

690 lines
25 KiB
Go

package searchquery
import (
"context"
"database/sql"
"fmt"
"net/url"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
)
// AuditLogs requires the database to fetch an organization by name
// to convert to organization uuid.
//
// Supported query parameters:
//
// - request_id: UUID (can be used to search for associated audits e.g. connect/disconnect or open/close)
// - resource_id: UUID
// - resource_target: string
// - username: string
// - email: string
// - date_from: string (date in format "2006-01-02")
// - date_to: string (date in format "2006-01-02")
// - organization: string (organization UUID or name)
// - resource_type: string (enum)
// - action: string (enum)
// - build_reason: string (enum)
func AuditLogs(ctx context.Context, db database.Store, query string) (database.GetAuditLogsOffsetParams,
database.CountAuditLogsParams, []codersdk.ValidationError,
) {
// Always lowercase for all searches.
query = strings.ToLower(query)
values, errors := searchTerms(query, func(term string, values url.Values) error {
values.Add("resource_type", term)
return nil
})
if len(errors) > 0 {
// nolint:exhaustruct // We don't need to initialize these structs because we return an error.
return database.GetAuditLogsOffsetParams{}, database.CountAuditLogsParams{}, errors
}
const dateLayout = "2006-01-02"
parser := httpapi.NewQueryParamParser()
filter := database.GetAuditLogsOffsetParams{
RequestID: parser.UUID(values, uuid.Nil, "request_id"),
ResourceID: parser.UUID(values, uuid.Nil, "resource_id"),
ResourceTarget: parser.String(values, "", "resource_target"),
Username: parser.String(values, "", "username"),
Email: parser.String(values, "", "email"),
DateFrom: parser.Time(values, time.Time{}, "date_from", dateLayout),
DateTo: parser.Time(values, time.Time{}, "date_to", dateLayout),
OrganizationID: parseOrganization(ctx, db, parser, values, "organization"),
ResourceType: string(httpapi.ParseCustom(parser, values, "", "resource_type", httpapi.ParseEnum[database.ResourceType])),
Action: string(httpapi.ParseCustom(parser, values, "", "action", httpapi.ParseEnum[database.AuditAction])),
BuildReason: string(httpapi.ParseCustom(parser, values, "", "build_reason", httpapi.ParseEnum[database.BuildReason])),
}
if !filter.DateTo.IsZero() {
filter.DateTo = filter.DateTo.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
}
// Prepare the count filter, which uses the same parameters as the GetAuditLogsOffsetParams.
// nolint:exhaustruct // UserID is not obtained from the query parameters.
countFilter := database.CountAuditLogsParams{
RequestID: filter.RequestID,
ResourceID: filter.ResourceID,
ResourceTarget: filter.ResourceTarget,
Username: filter.Username,
Email: filter.Email,
DateFrom: filter.DateFrom,
DateTo: filter.DateTo,
OrganizationID: filter.OrganizationID,
ResourceType: filter.ResourceType,
Action: filter.Action,
BuildReason: filter.BuildReason,
}
parser.ErrorExcessParams(values)
return filter, countFilter, parser.Errors
}
func ConnectionLogs(ctx context.Context, db database.Store, query string, apiKey database.APIKey) (database.GetConnectionLogsOffsetParams, database.CountConnectionLogsParams, []codersdk.ValidationError) {
// Always lowercase for all searches.
query = strings.ToLower(query)
values, errors := searchTerms(query, func(term string, values url.Values) error {
values.Add("search", term)
return nil
})
if len(errors) > 0 {
// nolint:exhaustruct // We don't need to initialize these structs because we return an error.
return database.GetConnectionLogsOffsetParams{}, database.CountConnectionLogsParams{}, errors
}
parser := httpapi.NewQueryParamParser()
filter := database.GetConnectionLogsOffsetParams{
OrganizationID: parseOrganization(ctx, db, parser, values, "organization"),
WorkspaceOwner: parser.String(values, "", "workspace_owner"),
WorkspaceOwnerEmail: parser.String(values, "", "workspace_owner_email"),
Type: string(httpapi.ParseCustom(parser, values, "", "type", httpapi.ParseEnum[database.ConnectionType])),
Username: parser.String(values, "", "username"),
UserEmail: parser.String(values, "", "user_email"),
ConnectedAfter: parser.Time3339Nano(values, time.Time{}, "connected_after"),
ConnectedBefore: parser.Time3339Nano(values, time.Time{}, "connected_before"),
WorkspaceID: parser.UUID(values, uuid.Nil, "workspace_id"),
ConnectionID: parser.UUID(values, uuid.Nil, "connection_id"),
Status: string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[codersdk.ConnectionLogStatus])),
}
if filter.Username == "me" {
filter.UserID = apiKey.UserID
filter.Username = ""
}
if filter.WorkspaceOwner == "me" {
filter.WorkspaceOwnerID = apiKey.UserID
filter.WorkspaceOwner = ""
}
// This MUST be kept in sync with the above
countFilter := database.CountConnectionLogsParams{
OrganizationID: filter.OrganizationID,
WorkspaceOwner: filter.WorkspaceOwner,
WorkspaceOwnerID: filter.WorkspaceOwnerID,
WorkspaceOwnerEmail: filter.WorkspaceOwnerEmail,
Type: filter.Type,
UserID: filter.UserID,
Username: filter.Username,
UserEmail: filter.UserEmail,
ConnectedAfter: filter.ConnectedAfter,
ConnectedBefore: filter.ConnectedBefore,
WorkspaceID: filter.WorkspaceID,
ConnectionID: filter.ConnectionID,
Status: filter.Status,
}
parser.ErrorExcessParams(values)
return filter, countFilter, parser.Errors
}
func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) {
// Always lowercase for all searches.
query = strings.ToLower(query)
values, errors := searchTerms(query, func(term string, values url.Values) error {
values.Add("search", term)
return nil
})
if len(errors) > 0 {
return database.GetUsersParams{}, errors
}
parser := httpapi.NewQueryParamParser()
filter := database.GetUsersParams{
Search: parser.String(values, "", "search"),
Name: parser.String(values, "", "name"),
Status: httpapi.ParseCustomList(parser, values, []database.UserStatus{}, "status", httpapi.ParseEnum[database.UserStatus]),
RbacRole: parser.Strings(values, []string{}, "role"),
LastSeenAfter: parser.Time3339Nano(values, time.Time{}, "last_seen_after"),
LastSeenBefore: parser.Time3339Nano(values, time.Time{}, "last_seen_before"),
CreatedAfter: parser.Time3339Nano(values, time.Time{}, "created_after"),
CreatedBefore: parser.Time3339Nano(values, time.Time{}, "created_before"),
GithubComUserID: parser.Int64(values, 0, "github_com_user_id"),
LoginType: httpapi.ParseCustomList(parser, values, []database.LoginType{}, "login_type", httpapi.ParseEnum[database.LoginType]),
}
parser.ErrorExcessParams(values)
return filter, parser.Errors
}
func Members(query string, organizationID uuid.UUID) (database.OrganizationMembersParams, []codersdk.ValidationError) {
query = strings.TrimSpace(query)
if query == "" {
return database.OrganizationMembersParams{
OrganizationID: organizationID,
UserID: uuid.Nil,
IncludeSystem: false,
GithubUserID: 0,
}, nil
}
values, errors := searchTerms(query, func(term string, values url.Values) error {
switch term {
case "user_id":
values.Set("user_id", "")
case "github_user_id":
values.Set("github_user_id", "")
case "include_system":
values.Set("include_system", "")
default:
return xerrors.Errorf("invalid search term: %s", term)
}
return nil
})
if len(errors) > 0 {
return database.OrganizationMembersParams{
OrganizationID: organizationID,
UserID: uuid.Nil,
IncludeSystem: false,
GithubUserID: 0,
}, errors
}
parser := httpapi.NewQueryParamParser()
params := database.OrganizationMembersParams{
OrganizationID: organizationID,
UserID: parser.UUID(values, uuid.Nil, "user_id"),
IncludeSystem: parser.Boolean(values, false, "include_system"),
GithubUserID: parser.Int64(values, 0, "github_user_id"),
}
parser.ErrorExcessParams(values)
return params, parser.Errors
}
func Workspaces(ctx context.Context, db database.Store, query string, page codersdk.Pagination, agentInactiveDisconnectTimeout time.Duration) (database.GetWorkspacesParams, []codersdk.ValidationError) {
filter := database.GetWorkspacesParams{
AgentInactiveDisconnectTimeoutSeconds: int64(agentInactiveDisconnectTimeout.Seconds()),
// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
Offset: int32(page.Offset),
// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
Limit: int32(page.Limit),
}
if query == "" {
return filter, nil
}
// Always lowercase for all searches.
query = strings.ToLower(query)
values, errors := searchTerms(query, func(term string, values url.Values) error {
// It is a workspace name, and maybe includes an owner
parts := splitQueryParameterByDelimiter(term, '/', false)
switch len(parts) {
case 1:
values.Add("name", parts[0])
case 2:
values.Add("owner", parts[0])
values.Add("name", parts[1])
default:
return xerrors.Errorf("Query element %q can only contain 1 '/'", term)
}
return nil
})
if len(errors) > 0 {
return filter, errors
}
parser := httpapi.NewQueryParamParser()
filter.WorkspaceIds = parser.UUIDs(values, []uuid.UUID{}, "id")
filter.OwnerUsername = parser.String(values, "", "owner")
filter.TemplateName = parser.String(values, "", "template")
filter.Name = parser.String(values, "", "name")
filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus]))
filter.HasAgentStatuses = parser.Strings(values, []string{}, "has-agent")
filter.Dormant = parser.Boolean(values, false, "dormant")
filter.LastUsedAfter = parser.Time3339Nano(values, time.Time{}, "last_used_after")
filter.LastUsedBefore = parser.Time3339Nano(values, time.Time{}, "last_used_before")
filter.UsingActive = sql.NullBool{
// Invert the value of the query parameter to get the correct value.
// UsingActive returns if the workspace is on the latest template active version.
Bool: !parser.Boolean(values, true, "outdated"),
// Only include this search term if it was provided. Otherwise default to omitting it
// which will return all workspaces.
Valid: values.Has("outdated"),
}
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")
// TODO: support "me" by passing in the actorID
filter.SharedWithUserID = parseUser(ctx, db, parser, values, "shared_with_user", uuid.Nil)
filter.SharedWithGroupID = parseGroup(ctx, db, parser, values, "shared_with_group")
// 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 {
if healthy.Bool {
filter.HasAgentStatuses = append(filter.HasAgentStatuses, "connected")
} else {
filter.HasAgentStatuses = append(filter.HasAgentStatuses, "disconnected", "timeout")
}
}
type paramMatch struct {
name string
value *string
}
// parameter matching takes the form of:
// `param:<name>[=<value>]`
// If the value is omitted, then we match on the presence of the parameter.
// If the value is provided, then we match on the parameter and value.
params := httpapi.ParseCustomList(parser, values, []paramMatch{}, "param", func(v string) (paramMatch, error) {
// Ignore excess spaces
v = strings.TrimSpace(v)
parts := strings.Split(v, "=")
if len(parts) == 1 {
// Only match on the presence of the parameter
return paramMatch{name: parts[0], value: nil}, nil
}
if len(parts) == 2 {
if parts[1] == "" {
return paramMatch{}, xerrors.Errorf("query element %q has an empty value. omit the '=' to match just on the parameter name", v)
}
// Match on the parameter and value
return paramMatch{name: parts[0], value: &parts[1]}, nil
}
return paramMatch{}, xerrors.Errorf("query element %q can only contain 1 '='", v)
})
for _, p := range params {
if p.value == nil {
filter.HasParam = append(filter.HasParam, p.name)
continue
}
filter.ParamNames = append(filter.ParamNames, p.name)
filter.ParamValues = append(filter.ParamValues, *p.value)
}
parser.ErrorExcessParams(values)
return filter, parser.Errors
}
func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query string) (database.GetTemplatesWithFilterParams, []codersdk.ValidationError) {
// Always lowercase for all searches.
query = strings.ToLower(query)
values, errors := searchTerms(query, func(term string, values url.Values) error {
// Default to the display name
values.Add("display_name", term)
return nil
})
if len(errors) > 0 {
return database.GetTemplatesWithFilterParams{}, errors
}
parser := httpapi.NewQueryParamParser()
filter := database.GetTemplatesWithFilterParams{
Deleted: parser.Boolean(values, false, "deleted"),
OrganizationID: parseOrganization(ctx, db, parser, values, "organization"),
ExactName: parser.String(values, "", "exact_name"),
ExactDisplayName: parser.String(values, "", "exact_display_name"),
FuzzyName: parser.String(values, "", "name"),
FuzzyDisplayName: parser.String(values, "", "display_name"),
IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"),
Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"),
HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"),
AuthorID: parser.UUID(values, uuid.Nil, "author_id"),
AuthorUsername: parser.String(values, "", "author"),
HasExternalAgent: parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent"),
}
if filter.AuthorUsername == codersdk.Me {
filter.AuthorID = actorID
filter.AuthorUsername = ""
}
parser.ErrorExcessParams(values)
return filter, parser.Errors
}
func AIBridgeInterceptions(ctx context.Context, db database.Store, query string, page codersdk.Pagination, actorID uuid.UUID) (database.ListAIBridgeInterceptionsParams, []codersdk.ValidationError) {
// nolint:exhaustruct // Empty values just means "don't filter by that field".
filter := database.ListAIBridgeInterceptionsParams{
AfterID: page.AfterID,
// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
Limit: int32(page.Limit),
// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
Offset: int32(page.Offset),
}
if query == "" {
return filter, nil
}
values, errors := searchTerms(query, func(term string, values url.Values) error {
// Default to the initiating user
values.Add("initiator", term)
return nil
})
if len(errors) > 0 {
return filter, errors
}
parser := httpapi.NewQueryParamParser()
filter.InitiatorID = parseUser(ctx, db, parser, values, "initiator", actorID)
filter.Provider = parser.String(values, "", "provider")
filter.Model = parser.String(values, "", "model")
filter.Client = parser.String(values, "", "client")
// Time must be between started_after and started_before.
filter.StartedAfter = parser.Time3339Nano(values, time.Time{}, "started_after")
filter.StartedBefore = parser.Time3339Nano(values, time.Time{}, "started_before")
if !filter.StartedBefore.IsZero() && !filter.StartedAfter.IsZero() && !filter.StartedBefore.After(filter.StartedAfter) {
parser.Errors = append(parser.Errors, codersdk.ValidationError{
Field: "started_before",
Detail: `Query param "started_before" has invalid value: "started_before" must be after "started_after" if set`,
})
}
parser.ErrorExcessParams(values)
return filter, parser.Errors
}
func AIBridgeModels(query string, page codersdk.Pagination) (database.ListAIBridgeModelsParams, []codersdk.ValidationError) {
// nolint:exhaustruct // Empty values just means "don't filter by that field".
filter := database.ListAIBridgeModelsParams{
// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
Offset: int32(page.Offset),
// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
Limit: int32(page.Limit),
}
if query == "" {
return filter, nil
}
values, errors := searchTerms(query, func(term string, values url.Values) error {
// Defaults to the `model` if no `key:value` pair is provided.
values.Add("model", term)
return nil
})
if len(errors) > 0 {
return filter, errors
}
parser := httpapi.NewQueryParamParser()
filter.Model = parser.String(values, "", "model")
parser.ErrorExcessParams(values)
return filter, parser.Errors
}
// Tasks parses a search query for tasks.
//
// Supported query parameters:
// - owner: string (username, UUID, or 'me' for current user)
// - organization: string (organization UUID or name)
// - status: string (pending, initializing, active, paused, error, unknown)
func Tasks(ctx context.Context, db database.Store, query string, actorID uuid.UUID) (database.ListTasksParams, []codersdk.ValidationError) {
filter := database.ListTasksParams{
OwnerID: uuid.Nil,
OrganizationID: uuid.Nil,
Status: "",
}
if query == "" {
return filter, nil
}
// Always lowercase for all searches.
query = strings.ToLower(query)
values, errors := searchTerms(query, func(term string, values url.Values) error {
// Default unqualified terms to owner
values.Add("owner", term)
return nil
})
if len(errors) > 0 {
return filter, errors
}
parser := httpapi.NewQueryParamParser()
filter.OwnerID = parseUser(ctx, db, parser, values, "owner", actorID)
filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization")
filter.Status = parser.String(values, "", "status")
parser.ErrorExcessParams(values)
return filter, parser.Errors
}
// Chats parses a search query for chats.
//
// Supported query parameters:
// - archived: boolean (default: false, excludes archived chats unless explicitly set)
func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) {
filter := database.GetChatsParams{
// Default to hiding archived chats.
Archived: sql.NullBool{Bool: false, Valid: true},
}
if query == "" {
return filter, nil
}
// Always lowercase for all searches.
query = strings.ToLower(query)
values, errors := searchTerms(query, func(term string, _ url.Values) error {
return xerrors.Errorf("unsupported search term: %q", term)
})
if len(errors) > 0 {
return filter, errors
}
parser := httpapi.NewQueryParamParser()
filter.Archived = parser.NullableBoolean(values, filter.Archived, "archived")
parser.ErrorExcessParams(values)
return filter, parser.Errors
}
func searchTerms(query string, defaultKey func(term string, values url.Values) error) (url.Values, []codersdk.ValidationError) {
searchValues := make(url.Values)
// Because we do this in 2 passes, we want to maintain quotes on the first
// pass. Further splitting occurs on the second pass and quotes will be
// dropped.
tokens := splitQueryParameterByDelimiter(query, ' ', true)
elements := processTokens(tokens)
for _, element := range elements {
if strings.HasPrefix(element, ":") || strings.HasSuffix(element, ":") {
return nil, []codersdk.ValidationError{
{
Field: "q",
Detail: fmt.Sprintf("Query element %q cannot start or end with ':'", element),
},
}
}
parts := splitQueryParameterByDelimiter(element, ':', false)
switch len(parts) {
case 1:
// No key:value pair. Use default behavior.
err := defaultKey(element, searchValues)
if err != nil {
return nil, []codersdk.ValidationError{
{Field: "q", Detail: err.Error()},
}
}
case 2:
searchValues.Add(strings.ToLower(parts[0]), parts[1])
default:
return nil, []codersdk.ValidationError{
{
Field: "q",
Detail: fmt.Sprintf("Query element %q can only contain 1 ':'", element),
},
}
}
}
return searchValues, nil
}
func parseOrganization(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
}
organizationID, err := uuid.Parse(v)
if err == nil {
return organizationID, nil
}
organization, err := db.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{
Name: v, Deleted: false,
})
if err != nil {
return uuid.Nil, xerrors.Errorf("organization %q either does not exist, or you are unauthorized to view it", v)
}
return organization.ID, nil
})
}
func parseUser(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string, actorID uuid.UUID) uuid.UUID {
return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) {
if v == "" {
return uuid.Nil, nil
}
if v == codersdk.Me && actorID != uuid.Nil {
return actorID, 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.
//
// Although all our names cannot have spaces, that is a validation error.
// We should still parse the quoted string as a single value so that validation
// can properly fail on the space. If we do not, a value of `template:"my name"`
// will search `template:"my name:name"`, which produces an empty list instead of
// an error.
// nolint:revive
func splitQueryParameterByDelimiter(query string, delimiter rune, maintainQuotes bool) []string {
quoted := false
parts := strings.FieldsFunc(query, func(r rune) bool {
if r == '"' {
quoted = !quoted
}
return !quoted && r == delimiter
})
if !maintainQuotes {
for i, part := range parts {
parts[i] = strings.Trim(part, "\"")
}
}
return parts
}
// processTokens takes the split tokens and groups them based on a delimiter (':').
// Tokens without a delimiter present are joined to support searching with spaces.
//
// Example Input: ['deprecated:false', 'test', 'template']
// Example Output: ['deprecated:false', 'test template']
func processTokens(tokens []string) []string {
var results []string
var nonFieldTerms []string
for _, token := range tokens {
if strings.Contains(token, string(':')) {
results = append(results, token)
} else {
nonFieldTerms = append(nonFieldTerms, token)
}
}
if len(nonFieldTerms) > 0 {
results = append(results, strings.Join(nonFieldTerms, " "))
}
return results
}