mirror of
https://github.com/coder/coder.git
synced 2026-06-06 06:28:20 +00:00
b779c9ee33
## 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 |
166 lines
5.7 KiB
Go
166 lines
5.7 KiB
Go
package regosql
|
|
|
|
import "github.com/coder/coder/v2/coderd/rbac/regosql/sqltypes"
|
|
|
|
func resourceIDMatcher() sqltypes.VariableMatcher {
|
|
return sqltypes.StringVarMatcher("id :: text", []string{"input", "object", "id"})
|
|
}
|
|
|
|
func organizationOwnerMatcher() sqltypes.VariableMatcher {
|
|
return sqltypes.StringVarMatcher("organization_id :: text", []string{"input", "object", "org_owner"})
|
|
}
|
|
|
|
func userOwnerMatcher() sqltypes.VariableMatcher {
|
|
return sqltypes.StringVarMatcher("owner_id :: text", []string{"input", "object", "owner"})
|
|
}
|
|
|
|
func groupACLMatcher(m sqltypes.VariableMatcher) ACLMappingVar {
|
|
return ACLMappingMatcher(m, "group_acl", []string{"input", "object", "acl_group_list"})
|
|
}
|
|
|
|
func userACLMatcher(m sqltypes.VariableMatcher) ACLMappingVar {
|
|
return ACLMappingMatcher(m, "user_acl", []string{"input", "object", "acl_user_list"})
|
|
}
|
|
|
|
func TemplateConverter() *sqltypes.VariableConverter {
|
|
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
|
|
resourceIDMatcher(),
|
|
sqltypes.StringVarMatcher("t.organization_id :: text", []string{"input", "object", "org_owner"}),
|
|
// Templates have no user owner, only owner by an organization.
|
|
sqltypes.AlwaysFalse(userOwnerMatcher()),
|
|
)
|
|
matcher.RegisterMatcher(
|
|
groupACLMatcher(matcher),
|
|
userACLMatcher(matcher),
|
|
)
|
|
return matcher
|
|
}
|
|
|
|
func WorkspaceConverter() *sqltypes.VariableConverter {
|
|
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
|
|
resourceIDMatcher(),
|
|
sqltypes.StringVarMatcher("workspaces.organization_id :: text", []string{"input", "object", "org_owner"}),
|
|
userOwnerMatcher(),
|
|
)
|
|
matcher.RegisterMatcher(
|
|
ACLMappingMatcher(matcher, "workspaces.group_acl", []string{"input", "object", "acl_group_list"}).UsingSubfield("permissions"),
|
|
ACLMappingMatcher(matcher, "workspaces.user_acl", []string{"input", "object", "acl_user_list"}).UsingSubfield("permissions"),
|
|
)
|
|
|
|
return matcher
|
|
}
|
|
|
|
func AuditLogConverter() *sqltypes.VariableConverter {
|
|
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
|
|
resourceIDMatcher(),
|
|
sqltypes.StringVarMatcher("COALESCE(audit_logs.organization_id :: text, '')", []string{"input", "object", "org_owner"}),
|
|
// Audit logs have no user owner, only owner by an organization.
|
|
sqltypes.AlwaysFalse(userOwnerMatcher()),
|
|
)
|
|
matcher.RegisterMatcher(
|
|
sqltypes.AlwaysFalse(groupACLMatcher(matcher)),
|
|
sqltypes.AlwaysFalse(userACLMatcher(matcher)),
|
|
)
|
|
return matcher
|
|
}
|
|
|
|
func ConnectionLogConverter() *sqltypes.VariableConverter {
|
|
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
|
|
resourceIDMatcher(),
|
|
sqltypes.StringVarMatcher("COALESCE(connection_logs.organization_id :: text, '')", []string{"input", "object", "org_owner"}),
|
|
// Connection logs have no user owner, only owner by an organization.
|
|
sqltypes.AlwaysFalse(userOwnerMatcher()),
|
|
)
|
|
matcher.RegisterMatcher(
|
|
sqltypes.AlwaysFalse(groupACLMatcher(matcher)),
|
|
sqltypes.AlwaysFalse(userACLMatcher(matcher)),
|
|
)
|
|
return matcher
|
|
}
|
|
|
|
func AIBridgeInterceptionConverter() *sqltypes.VariableConverter {
|
|
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
|
|
resourceIDMatcher(),
|
|
// AI Bridge interceptions are not tied to any organization.
|
|
sqltypes.StringVarMatcher("''", []string{"input", "object", "org_owner"}),
|
|
sqltypes.StringVarMatcher("initiator_id :: text", []string{"input", "object", "owner"}),
|
|
)
|
|
matcher.RegisterMatcher(
|
|
// No ACLs on the aibridge interception type
|
|
sqltypes.AlwaysFalse(groupACLMatcher(matcher)),
|
|
sqltypes.AlwaysFalse(userACLMatcher(matcher)),
|
|
)
|
|
return matcher
|
|
}
|
|
|
|
func UserConverter() *sqltypes.VariableConverter {
|
|
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
|
|
resourceIDMatcher(),
|
|
// Users are never owned by an organization, so always return the empty string
|
|
// for the org owner.
|
|
sqltypes.StringVarMatcher("''", []string{"input", "object", "org_owner"}),
|
|
// Users are always owned by themselves.
|
|
sqltypes.StringVarMatcher("id :: text", []string{"input", "object", "owner"}),
|
|
)
|
|
matcher.RegisterMatcher(
|
|
// No ACLs on the user type
|
|
sqltypes.AlwaysFalse(groupACLMatcher(matcher)),
|
|
sqltypes.AlwaysFalse(userACLMatcher(matcher)),
|
|
)
|
|
return matcher
|
|
}
|
|
|
|
// NoACLConverter should be used when the target SQL table does not contain
|
|
// group or user ACL columns.
|
|
func NoACLConverter() *sqltypes.VariableConverter {
|
|
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
|
|
resourceIDMatcher(),
|
|
organizationOwnerMatcher(),
|
|
userOwnerMatcher(),
|
|
)
|
|
matcher.RegisterMatcher(
|
|
sqltypes.AlwaysFalse(groupACLMatcher(matcher)),
|
|
sqltypes.AlwaysFalse(userACLMatcher(matcher)),
|
|
)
|
|
|
|
return matcher
|
|
}
|
|
|
|
// ChatConverter should be used for the chats table, which has no
|
|
// organization_id, group_acl, or user_acl columns.
|
|
func ChatConverter() *sqltypes.VariableConverter {
|
|
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
|
|
resourceIDMatcher(),
|
|
// The chats table has no organization_id column. Map org_owner
|
|
// to a literal empty string so that:
|
|
// - User-level ownership checks (org_owner = '') activate correctly.
|
|
// - Org-scoped permissions never match (org_owner will never equal
|
|
// a real org UUID), which is intentional since chats are not
|
|
// org-scoped resources.
|
|
// Note: custom org roles that include "chat" permissions will
|
|
// silently have no effect because of this mapping.
|
|
sqltypes.StringVarMatcher("''", []string{"input", "object", "org_owner"}),
|
|
userOwnerMatcher(),
|
|
)
|
|
matcher.RegisterMatcher(
|
|
sqltypes.AlwaysFalse(groupACLMatcher(matcher)),
|
|
sqltypes.AlwaysFalse(userACLMatcher(matcher)),
|
|
)
|
|
|
|
return matcher
|
|
}
|
|
|
|
func DefaultVariableConverter() *sqltypes.VariableConverter {
|
|
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
|
|
resourceIDMatcher(),
|
|
organizationOwnerMatcher(),
|
|
userOwnerMatcher(),
|
|
)
|
|
matcher.RegisterMatcher(
|
|
groupACLMatcher(matcher),
|
|
userACLMatcher(matcher),
|
|
)
|
|
|
|
return matcher
|
|
}
|