mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
## Problem
This is a bug fix. Chat model sharing UAT (main @ 3c32408) found two
bugs when a delegated "Model Sharer" role holds only
`chat_model_config:read` + `chat_model_config:share`:
This state is reachable entirely through the UI in a normal
least-privilege setup:
1. An org admin restricts **Organization Settings → Workspace Sharing**
to `none` or `service_accounts`.
2. Under **Organization → Roles**, they create a custom role with only
the advanced permissions `chat_model_config:read` and
`chat_model_config:share`.
3. Under **Organization → Members**, they assign that role to the person
responsible for managing model access.
4. An admin creates a chat model and may initially share it with a
group.
5. The delegated sharer signs in, opens **AI Settings → Models → [model]
→ Share model**, where the failures below occur.
- **UAT-05 / UAT-08:** with workspace sharing set to `none`, the sharing
dialog errored out entirely ("Resource not found or you do not have
access to this resource"). The sharer couldn't view, add, or even remove
grants.
- **UAT-04:** with workspace sharing set to `service_accounts`, existing
group grants rendered as raw UUIDs, and groups didn't appear in the
add-principal search.
Both have the same cause. The ACL GET returned bare UUID→role maps, so
to display names (and to power its add-principal search) the dialog
called the generic org directory APIs: list organization members and
list groups. Those APIs require `organization_member:read` and
`group:read`, which the sharer role doesn't have. Ordinary members only
get them as a side effect of a *different* feature:
`OrgMemberPermissions` grants member read when the org's workspace
sharing mode isn't `none` (so users can pick coworkers to share a
workspace with), and group read only when it's `everyone`. The chat
dialog was silently borrowing those workspace-sharing permissions, so it
only worked when that unrelated org setting happened to grant them:
- `none`: no member read → dbauthz rejects the member listing → the
dialog's query chain 404s and everything blanks (UAT-05/08).
- `service_accounts`: member read but no group read → the group listing
silently filters to empty → group grants can't be resolved past their
UUIDs (UAT-04).
In short: an org setting about who can share workspaces was deciding
whether a delegated sharer could manage a chat model ACL.
## Fix
Stop borrowing directory permissions entirely; authorize everything
through the model's own share permission, using the two patterns the
repo already has for ACL editors:
- **Hydrate the ACL GET** to return resolved users and groups instead of
UUID maps, like `workspaceACL` and the per-chat ACL already do. Fixes
the raw UUIDs.
- **Add `GET .../acl/available`** returning assignable org members and
groups for the autocomplete, modeled directly on
`templateAvailablePermissions` (same query semantics, same
share-gate-then-system-context lookup that every sibling ACL endpoint
uses). Fixes candidate discovery. The new endpoint exists only because
the dialog has no permission-safe way to enumerate principals today; it
is plumbing for the fix, not a new feature.
This is exactly how template sharing already solves the same problem:
`templateACL` returns hydrated principals, and
`templateAvailablePermissions` gates on the template then does the
lookups under a system context because, per its own comment, "the caller
might not have permission to read all users". We differ from the
template version only where newer conventions exist:
| | Template | This PR |
|---|---|---|
| Gate | `ActionUpdate` on the template (predates `share`) |
`ActionShare` on the model, matching the workspace/chat/MCP ACL
endpoints |
| User candidates | Site-wide `GetUsers` | Non-system members of the
model's org only |
| Group hydration | Full member rosters | Member counts, batched in one
query |
The dialog now runs off the hydrated ACL plus the new endpoint, so all
three sharing modes take the same code path, and a discovery failure no
longer blanks existing grants. `OrgMemberPermissions`, the directory
APIs, and the PATCH (still a sparse UUID→role delta) are untouched.
## Diff breakdown
+1274/−191, but only about a third is product code:
- **Product (~450):** the two endpoints and hydration helpers
(`exp_chats_model_acl.go`, ~240), the dialog rework plus a new colocated
autocomplete component (~200), and SDK/site API plumbing (~60).
- **Tests (~625):** backend tests updated to hydrated shapes plus new
tests that reproduce the two failing UAT sharing modes end-to-end
(+418), rewritten and new story play tests (+198), and query/mock
updates.
- **Generated (~200):** swagger, apidocs, `typesGenerated.ts`, API
reference docs.
Depends on #28498 (stacked on the `/api/v2` chat API promotion; the new
endpoint is registered under both prefixes like its siblings).
---------
Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
280 lines
12 KiB
Go
280 lines
12 KiB
Go
package coderd
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// chatFilesRateLimitMW returns the middleware enforcing FilesRateLimit
|
|
// on chat file routes. Both API prefixes mount the same instance, and
|
|
// the limiter keys on a prefix-stripped endpoint, so alternating
|
|
// prefixes cannot double the budget.
|
|
func (api *API) chatFilesRateLimitMW() func(http.Handler) http.Handler {
|
|
api.chatFilesRateLimitOnce.Do(func() {
|
|
api.chatFilesRateLimit = httpmw.RateLimitByAPICompatibilityEndpoint(api.FilesRateLimit, time.Minute)
|
|
})
|
|
return api.chatFilesRateLimit
|
|
}
|
|
|
|
// chatAPIPrefix identifies which API prefix a chat route mount serves.
|
|
type chatAPIPrefix int
|
|
|
|
const (
|
|
chatAPIPrefixV2 chatAPIPrefix = iota
|
|
chatAPIPrefixExperimental
|
|
)
|
|
|
|
// injectDefaultOrganizationParam lets the legacy default-organization
|
|
// routes reuse the organization-scoped handlers.
|
|
func injectDefaultOrganizationParam(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
chi.RouteContext(req.Context()).URLParams.Add("organization", codersdk.DefaultOrganization)
|
|
next.ServeHTTP(rw, req)
|
|
})
|
|
}
|
|
|
|
// registerChatAPIRoutes mounts the chat API surface on r, the root router
|
|
// of an API prefix. /api/v2 and /api/experimental serve the same promoted
|
|
// routes during the CODAGT-921 compatibility window. The experimental
|
|
// mount also serves the routes that were not promoted, while /api/v2
|
|
// reserves their path segments so they return 404 instead of falling into
|
|
// the {chat} wildcard.
|
|
// TODO(CODAGT-921): unmount from /api/experimental after the transition
|
|
// window (tracked in CODAGT-922).
|
|
func (api *API) registerChatAPIRoutes(r chi.Router, apiKeyMiddleware func(http.Handler) http.Handler, prefix chatAPIPrefix) {
|
|
experimental := prefix == chatAPIPrefixExperimental
|
|
// Signed URL tokens authenticate downloads, so the route stays
|
|
// outside the API key middleware.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(api.chatFilesRateLimitMW())
|
|
r.Get("/chats/files/{file}/download", api.downloadChatFile)
|
|
})
|
|
if experimental {
|
|
// Superseded by the organization-scoped models collection and
|
|
// deliberately not promoted. Keep until the frontend uses the
|
|
// organization-scoped routes.
|
|
r.Route("/chats/model-configs", func(r chi.Router) {
|
|
r.Use(
|
|
apiKeyMiddleware,
|
|
injectDefaultOrganizationParam,
|
|
httpmw.ExtractOrganizationParam(api.Database),
|
|
)
|
|
r.Get("/", api.listDefaultOrganizationChatModelConfigs)
|
|
r.Post("/", api.createChatModelConfig)
|
|
})
|
|
}
|
|
r.Route("/chats", func(r chi.Router) {
|
|
r.Use(apiKeyMiddleware)
|
|
if experimental {
|
|
// Superseded by the organization-scoped models route and
|
|
// deliberately not promoted. Keep until the frontend uses
|
|
// the organization-scoped routes.
|
|
r.With(
|
|
injectDefaultOrganizationParam,
|
|
httpmw.ExtractOrganizationParam(api.Database),
|
|
).Get("/models", api.listChatModelConfigsByOrganization)
|
|
// TODO(cian): place under /api/experimental/chats/config
|
|
r.Route("/providers", func(r chi.Router) {
|
|
r.Get("/", api.listChatProviders)
|
|
r.Post("/", api.createChatProvider)
|
|
r.Route("/{providerConfig}", func(r chi.Router) {
|
|
r.Patch("/", api.updateChatProvider)
|
|
r.Delete("/", api.deleteChatProvider)
|
|
})
|
|
})
|
|
r.Route("/user-provider-configs", func(r chi.Router) {
|
|
r.Get("/", api.listUserChatProviderConfigs)
|
|
r.Route("/{providerConfig}", func(r chi.Router) {
|
|
r.Put("/", api.upsertUserChatProviderKey)
|
|
r.Delete("/", api.deleteUserChatProviderKey)
|
|
})
|
|
})
|
|
} else {
|
|
// These segments exist only under /api/experimental. Reserve
|
|
// them with empty subrouters so they return 404 instead of
|
|
// falling into the {chat} wildcard and failing UUID parsing
|
|
// with a 400.
|
|
// TODO(CODAGT-922): drop the reservations with the
|
|
// experimental mounts.
|
|
for _, segment := range []string{"/models", "/model-configs", "/providers", "/user-provider-configs"} {
|
|
r.Route(segment, func(r chi.Router) {
|
|
r.NotFound(func(rw http.ResponseWriter, _ *http.Request) {
|
|
httpapi.RouteNotFound(rw)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
r.Get("/by-workspace", api.chatsByWorkspace)
|
|
r.Get("/", api.listChats)
|
|
r.Post("/", api.postChats)
|
|
r.Get("/watch", api.watchChats)
|
|
r.Route("/files", func(r chi.Router) {
|
|
r.Use(api.chatFilesRateLimitMW())
|
|
r.Post("/", api.postChatFile)
|
|
r.Post("/{file}/download-url", api.postChatFileDownloadURL)
|
|
r.Get("/{file}", api.chatFileByID)
|
|
})
|
|
r.Route("/config", func(r chi.Router) {
|
|
r.Get("/system-prompt", api.getChatSystemPrompt)
|
|
r.Put("/system-prompt", api.putChatSystemPrompt)
|
|
r.Get("/plan-mode-instructions", api.getChatPlanModeInstructions)
|
|
r.Put("/plan-mode-instructions", api.putChatPlanModeInstructions)
|
|
r.Get("/personal-model-overrides", api.getChatPersonalModelOverridesAdminSettings)
|
|
r.Put("/personal-model-overrides", api.putChatPersonalModelOverridesAdminSettings)
|
|
r.Get("/debug-logging", api.getChatDebugLogging)
|
|
r.Put("/debug-logging", api.putChatDebugLogging)
|
|
r.Get("/user-debug-logging", api.getUserChatDebugLogging)
|
|
r.Put("/user-debug-logging", api.putUserChatDebugLogging)
|
|
r.Get("/user-prompt", api.getUserChatCustomPrompt)
|
|
r.Put("/user-prompt", api.putUserChatCustomPrompt)
|
|
r.Get("/user-compaction-thresholds", api.getUserChatCompactionThresholds)
|
|
r.Put("/user-compaction-thresholds/{modelConfig}", api.putUserChatCompactionThreshold)
|
|
r.Delete("/user-compaction-thresholds/{modelConfig}", api.deleteUserChatCompactionThreshold)
|
|
r.Get("/workspace-ttl", api.getChatWorkspaceTTL)
|
|
r.Put("/workspace-ttl", api.putChatWorkspaceTTL)
|
|
r.Get("/retention-days", api.getChatRetentionDays)
|
|
r.Put("/retention-days", api.putChatRetentionDays)
|
|
r.Get("/debug-retention-days", api.getChatDebugRetentionDays)
|
|
r.Put("/debug-retention-days", api.putChatDebugRetentionDays)
|
|
r.Get("/auto-archive-days", api.getChatAutoArchiveDays)
|
|
r.Put("/auto-archive-days", api.putChatAutoArchiveDays)
|
|
if experimental {
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentChatVirtualDesktop))
|
|
r.Get("/computer-use-provider", api.getChatComputerUseProvider)
|
|
r.Put("/computer-use-provider", api.putChatComputerUseProvider)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentChatAdvisor))
|
|
r.Get("/advisor", api.getChatAdvisorConfig)
|
|
r.Put("/advisor", api.putChatAdvisorConfig)
|
|
})
|
|
}
|
|
})
|
|
r.Route("/{chat}", func(r chi.Router) {
|
|
r.Use(httpmw.ExtractChatParam(api.Database))
|
|
r.Route("/acl", func(r chi.Router) {
|
|
r.Get("/", api.getChatACL)
|
|
r.Patch("/", api.patchChatACL)
|
|
})
|
|
r.Get("/", api.getChat)
|
|
r.Patch("/", api.patchChat)
|
|
r.Get("/cost", api.getChatCost)
|
|
r.Get("/messages", api.getChatMessages)
|
|
r.Post("/messages", api.postChatMessages)
|
|
r.Patch("/messages/{message}", api.patchChatMessage)
|
|
r.Get("/prompts", api.getChatUserPrompts)
|
|
r.Post("/interrupt", api.interruptChat)
|
|
r.Post("/compact", api.compactChat)
|
|
r.Post("/reconcile-invalid", api.reconcileInvalidChatState)
|
|
r.Post("/tool-results", api.postChatToolResults)
|
|
r.Post("/title/propose", api.proposeChatTitle)
|
|
r.Get("/diff", api.getChatDiffContents)
|
|
r.Put("/context", api.refreshChatContext)
|
|
r.Route("/queue/{queuedMessage}", func(r chi.Router) {
|
|
r.Delete("/", api.deleteChatQueuedMessage)
|
|
r.Post("/promote", api.promoteChatQueuedMessage)
|
|
})
|
|
r.Route("/stream", func(r chi.Router) {
|
|
r.Get("/", api.streamChat)
|
|
r.Get("/parts", api.streamChatParts)
|
|
r.Get("/git", api.watchChatGit)
|
|
if experimental {
|
|
r.Get("/desktop", api.watchChatDesktop)
|
|
}
|
|
})
|
|
if experimental {
|
|
r.Route("/debug", func(r chi.Router) {
|
|
r.Get("/runs", api.getChatDebugRuns)
|
|
r.Get("/runs/{debugRun}", api.getChatDebugRun)
|
|
})
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
// registerMCPServerOAuth2Routes mounts the user-scoped MCP server OAuth2
|
|
// routes shared by both API prefixes.
|
|
func (api *API) registerMCPServerOAuth2Routes(r chi.Router, prefix chatAPIPrefix) {
|
|
if prefix == chatAPIPrefixExperimental {
|
|
// Providers pin the redirect URI when a session is established,
|
|
// so the callback URL cannot change for existing sessions without
|
|
// breaking token refresh and forcing a re-auth.
|
|
// TODO(CODAGT-922): promote once existing sessions can be
|
|
// re-authenticated against a /api/v2 callback.
|
|
r.Get("/servers/{mcpServer}/oauth2/callback", api.mcpServerOAuth2Callback)
|
|
}
|
|
// Disconnect stays outside organization routes so former organization
|
|
// members can delete their stored token after losing config read access.
|
|
r.Delete("/servers/{mcpServer}/oauth2/disconnect", api.mcpServerOAuth2Disconnect)
|
|
}
|
|
|
|
func (api *API) registerUserAIProviderKeyRoutes(r chi.Router) {
|
|
r.Get("/", api.listUserAIProviderKeyConfigs)
|
|
r.Route("/{aiProvider}", func(r chi.Router) {
|
|
r.Put("/", api.upsertUserAIProviderKey)
|
|
r.Delete("/", api.deleteUserAIProviderKey)
|
|
})
|
|
}
|
|
|
|
// registerOrganizationChatRoutes mounts the organization-scoped chat and
|
|
// MCP server configuration routes; r must already extract the
|
|
// organization parameter.
|
|
func (api *API) registerOrganizationChatRoutes(r chi.Router, prefix chatAPIPrefix) {
|
|
r.Route("/mcp-servers", func(r chi.Router) {
|
|
r.Get("/", api.listMCPServerConfigs)
|
|
r.Post("/", api.createMCPServerConfig)
|
|
r.Route("/{mcpserverconfig}", func(r chi.Router) {
|
|
r.With(httpmw.ExtractMCPServerConfigParam(api.Database, api.HTTPAuth.Authorize,
|
|
policy.ActionRead, policy.ActionUpdate, policy.ActionDelete)).Get("/", api.getMCPServerConfig)
|
|
r.With(httpmw.ExtractMCPServerConfigParam(api.Database, api.HTTPAuth.Authorize,
|
|
policy.ActionUpdate)).Patch("/", api.updateMCPServerConfig)
|
|
r.With(httpmw.ExtractMCPServerConfigParam(api.Database, api.HTTPAuth.Authorize,
|
|
policy.ActionDelete)).Delete("/", api.deleteMCPServerConfig)
|
|
r.With(httpmw.ExtractMCPServerConfigParam(api.Database, api.HTTPAuth.Authorize,
|
|
policy.ActionShare)).Get("/acl", api.mcpServerConfigACL)
|
|
r.With(httpmw.ExtractMCPServerConfigParam(api.Database, api.HTTPAuth.Authorize,
|
|
policy.ActionShare)).Patch("/acl", api.patchMCPServerConfigACL)
|
|
if prefix == chatAPIPrefixV2 {
|
|
r.With(httpmw.ExtractMCPServerConfigParam(api.Database, api.HTTPAuth.Authorize,
|
|
policy.ActionShare)).Get("/acl/available", api.mcpServerConfigACLAvailable)
|
|
}
|
|
r.With(httpmw.ExtractMCPServerConfigParam(api.Database, api.HTTPAuth.Authorize,
|
|
policy.ActionRead)).Get("/oauth2/connect", api.mcpServerOAuth2Connect)
|
|
})
|
|
})
|
|
r.Route("/chats/model-overrides", func(r chi.Router) {
|
|
r.Get("/", api.getOrganizationChatModelOverrides)
|
|
r.Put("/{context}", api.putOrganizationChatModelOverride)
|
|
})
|
|
r.Route("/chats/models", func(r chi.Router) {
|
|
r.Get("/", api.listChatModelConfigsByOrganization)
|
|
r.Post("/", api.createChatModelConfig)
|
|
r.Route("/{model}", func(r chi.Router) {
|
|
r.Use(httpmw.ExtractChatModelConfigParam(api.Database))
|
|
r.Get("/", api.getChatModelConfig)
|
|
r.Patch("/", api.updateChatModelConfig)
|
|
r.Delete("/", api.deleteChatModelConfig)
|
|
r.Route("/acl", func(r chi.Router) {
|
|
r.Get("/", api.chatModelConfigACLHandler)
|
|
r.Patch("/", api.updateChatModelConfigACL)
|
|
r.Get("/available", api.chatModelConfigACLAvailable)
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
func (api *API) registerOrganizationMemberChatRoutes(r chi.Router) {
|
|
r.Route("/chats/model-overrides", func(r chi.Router) {
|
|
r.Get("/", api.getUserChatPersonalModelOverrides)
|
|
r.Put("/{context}", api.putUserChatPersonalModelOverride)
|
|
})
|
|
}
|