mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add deployment-wide template allowlist for chats (#23262)
- Stores a deployment-wide agents template allowlist in `site_configs` (`agents_template_allowlist`) - Adds `GET/PUT /api/experimental/chats/config/template-allowlist` endpoints - Filters `list_templates`, `read_template`, and `create_workspace` chat tools by allowlist, if defined (empty=all allowed) - Add "Templates" admin settings tab in Agents UI ([what it looks like](https://624de63c6aacee003aa84340-sitjilsyrr.chromatic.com/?path=/story/pages-agentspage-agentsettingspageview--template-allowlist)) > 🤖 This PR was created with the help of Coder Agents, and has been reviewed by my human. 🧑💻
This commit is contained in:
+50
-4
@@ -3371,6 +3371,45 @@ func (p *Server) runChat(
|
||||
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
|
||||
}),
|
||||
}
|
||||
// getAllowedTemplateIDs returns the current deployment-wide
|
||||
// template allowlist, re-reading from the database on each call
|
||||
// so that admin changes take effect without restarting the chat.
|
||||
// Returns nil (= all allowed) on errors to fail open.
|
||||
getAllowedTemplateIDs := func() map[uuid.UUID]bool {
|
||||
raw, err := p.db.GetChatTemplateAllowlist(ctx)
|
||||
if err != nil {
|
||||
p.logger.Error(ctx, "failed to load template allowlist, all templates will be allowed", slog.Error(err))
|
||||
return nil
|
||||
}
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var ids []string
|
||||
if jsonErr := json.Unmarshal([]byte(raw), &ids); jsonErr != nil {
|
||||
// Note: the API endpoint (GET /template-allowlist) returns
|
||||
// HTTP 500 for corrupt JSON, giving admins visibility into
|
||||
// the problem. The runtime path here deliberately fails open
|
||||
// so that a corrupt allowlist doesn't block all chats.
|
||||
p.logger.Error(ctx, "failed to parse template allowlist JSON, all templates will be allowed",
|
||||
slog.F("raw", raw), slog.Error(jsonErr))
|
||||
return nil
|
||||
}
|
||||
allowlist := make(map[uuid.UUID]bool, len(ids))
|
||||
for _, s := range ids {
|
||||
if id, parseErr := uuid.Parse(s); parseErr == nil {
|
||||
allowlist[id] = true
|
||||
} else {
|
||||
p.logger.Warn(ctx, "ignoring invalid UUID in template allowlist",
|
||||
slog.F("value", s), slog.Error(parseErr))
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 && len(allowlist) == 0 {
|
||||
p.logger.Error(ctx, "all UUIDs in template allowlist were invalid, all templates will be allowed",
|
||||
slog.F("count", len(ids)))
|
||||
return nil
|
||||
}
|
||||
return allowlist
|
||||
}
|
||||
// Only root chats (not delegated subagents) get workspace
|
||||
// provisioning and subagent tools. Child agents must not
|
||||
// create workspaces or spawn further subagents — they should
|
||||
@@ -3379,12 +3418,14 @@ func (p *Server) runChat(
|
||||
// Workspace provisioning tools.
|
||||
tools = append(tools,
|
||||
chattool.ListTemplates(chattool.ListTemplatesOptions{
|
||||
DB: p.db,
|
||||
OwnerID: chat.OwnerID,
|
||||
DB: p.db,
|
||||
OwnerID: chat.OwnerID,
|
||||
AllowedTemplateIDs: getAllowedTemplateIDs,
|
||||
}),
|
||||
chattool.ReadTemplate(chattool.ReadTemplateOptions{
|
||||
DB: p.db,
|
||||
OwnerID: chat.OwnerID,
|
||||
DB: p.db,
|
||||
OwnerID: chat.OwnerID,
|
||||
AllowedTemplateIDs: getAllowedTemplateIDs,
|
||||
}),
|
||||
chattool.CreateWorkspace(chattool.CreateWorkspaceOptions{
|
||||
DB: p.db,
|
||||
@@ -3395,7 +3436,12 @@ func (p *Server) runChat(
|
||||
AgentInactiveDisconnectTimeout: p.agentInactiveDisconnectTimeout,
|
||||
WorkspaceMu: &workspaceMu,
|
||||
Logger: p.logger,
|
||||
AllowedTemplateIDs: getAllowedTemplateIDs,
|
||||
}),
|
||||
// StartWorkspace intentionally does not enforce the
|
||||
// template allowlist. The allowlist restricts creation
|
||||
// of new workspaces only — existing workspaces can
|
||||
// be restarted regardless of allowlist changes.
|
||||
chattool.StartWorkspace(chattool.StartWorkspaceOptions{
|
||||
DB: p.db,
|
||||
OwnerID: chat.OwnerID,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// toolResponse builds a fantasy.ToolResponse from a JSON-serializable
|
||||
@@ -31,3 +32,17 @@ func truncateRunes(value string, maxLen int) string {
|
||||
}
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
|
||||
// isTemplateAllowed checks whether a template ID is permitted by the
|
||||
// configured allowlist. A nil function or an empty allowlist means
|
||||
// all templates are allowed.
|
||||
func isTemplateAllowed(getAllowlist func() map[uuid.UUID]bool, id uuid.UUID) bool {
|
||||
if getAllowlist == nil {
|
||||
return true
|
||||
}
|
||||
allowlist := getAllowlist()
|
||||
if len(allowlist) == 0 {
|
||||
return true
|
||||
}
|
||||
return allowlist[id]
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ type CreateWorkspaceOptions struct {
|
||||
AgentInactiveDisconnectTimeout time.Duration
|
||||
WorkspaceMu *sync.Mutex
|
||||
Logger slog.Logger
|
||||
AllowedTemplateIDs func() map[uuid.UUID]bool
|
||||
}
|
||||
|
||||
type createWorkspaceArgs struct {
|
||||
@@ -106,6 +107,10 @@ func CreateWorkspace(options CreateWorkspaceOptions) fantasy.AgentTool {
|
||||
), nil
|
||||
}
|
||||
|
||||
if !isTemplateAllowed(options.AllowedTemplateIDs, templateID) {
|
||||
return fantasy.NewTextErrorResponse("template not available for chat workspaces; use list_templates to find allowed templates"), nil
|
||||
}
|
||||
|
||||
// Serialize workspace creation to prevent parallel
|
||||
// tool calls from creating duplicate workspaces.
|
||||
if options.WorkspaceMu != nil {
|
||||
@@ -121,7 +126,6 @@ func CreateWorkspace(options CreateWorkspaceOptions) fantasy.AgentTool {
|
||||
if done {
|
||||
return toolResponse(existing), nil
|
||||
}
|
||||
|
||||
ownerID := options.OwnerID
|
||||
|
||||
// Set up dbauthz context for DB lookups.
|
||||
|
||||
@@ -3,6 +3,8 @@ package chattool
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -20,8 +22,9 @@ const listTemplatesPageSize = 10
|
||||
|
||||
// ListTemplatesOptions configures the list_templates tool.
|
||||
type ListTemplatesOptions struct {
|
||||
DB database.Store
|
||||
OwnerID uuid.UUID
|
||||
DB database.Store
|
||||
OwnerID uuid.UUID
|
||||
AllowedTemplateIDs func() map[uuid.UUID]bool
|
||||
}
|
||||
|
||||
type listTemplatesArgs struct {
|
||||
@@ -63,6 +66,13 @@ func ListTemplates(options ListTemplatesOptions) fantasy.AgentTool {
|
||||
filterParams.FuzzyName = query
|
||||
}
|
||||
|
||||
var allowlist map[uuid.UUID]bool
|
||||
if options.AllowedTemplateIDs != nil {
|
||||
allowlist = options.AllowedTemplateIDs()
|
||||
}
|
||||
if len(allowlist) > 0 {
|
||||
filterParams.IDs = slices.Collect(maps.Keys(allowlist))
|
||||
}
|
||||
templates, err := options.DB.GetTemplatesWithFilter(ctx, filterParams)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package chattool_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
//nolint:tparallel,paralleltest // Subtests share a single DB and run sequentially.
|
||||
func TestTemplateAllowlistEnforcement(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
UserID: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
})
|
||||
|
||||
t1 := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: org.ID,
|
||||
CreatedBy: user.ID,
|
||||
Name: "template-alpha",
|
||||
})
|
||||
t2 := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: org.ID,
|
||||
CreatedBy: user.ID,
|
||||
Name: "template-beta",
|
||||
})
|
||||
|
||||
t.Run("ListTemplates", func(t *testing.T) {
|
||||
t.Run("NoAllowlist", func(t *testing.T) {
|
||||
tool := chattool.ListTemplates(chattool.ListTemplatesOptions{
|
||||
DB: db,
|
||||
OwnerID: user.ID,
|
||||
})
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c1", Name: "list_templates", Input: "{}"})
|
||||
require.NoError(t, err)
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
templates := result["templates"].([]any)
|
||||
require.Len(t, templates, 2)
|
||||
})
|
||||
|
||||
t.Run("EmptyAllowlist", func(t *testing.T) {
|
||||
tool := chattool.ListTemplates(chattool.ListTemplatesOptions{
|
||||
DB: db,
|
||||
OwnerID: user.ID,
|
||||
AllowedTemplateIDs: func() map[uuid.UUID]bool { return map[uuid.UUID]bool{} },
|
||||
})
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c2", Name: "list_templates", Input: "{}"})
|
||||
require.NoError(t, err)
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
templates := result["templates"].([]any)
|
||||
require.Len(t, templates, 2)
|
||||
})
|
||||
|
||||
t.Run("OneMatch", func(t *testing.T) {
|
||||
tool := chattool.ListTemplates(chattool.ListTemplatesOptions{
|
||||
DB: db,
|
||||
OwnerID: user.ID,
|
||||
AllowedTemplateIDs: func() map[uuid.UUID]bool { return map[uuid.UUID]bool{t1.ID: true} },
|
||||
})
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c3", Name: "list_templates", Input: "{}"})
|
||||
require.NoError(t, err)
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
templates := result["templates"].([]any)
|
||||
require.Len(t, templates, 1)
|
||||
m := templates[0].(map[string]any)
|
||||
require.Equal(t, t1.ID.String(), m["id"].(string))
|
||||
})
|
||||
|
||||
t.Run("NoMatches", func(t *testing.T) {
|
||||
tool := chattool.ListTemplates(chattool.ListTemplatesOptions{
|
||||
DB: db,
|
||||
OwnerID: user.ID,
|
||||
AllowedTemplateIDs: func() map[uuid.UUID]bool { return map[uuid.UUID]bool{uuid.New(): true} },
|
||||
})
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c4", Name: "list_templates", Input: "{}"})
|
||||
require.NoError(t, err)
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
templates := result["templates"].([]any)
|
||||
require.Empty(t, templates)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ReadTemplate", func(t *testing.T) {
|
||||
t.Run("Allowed", func(t *testing.T) {
|
||||
tool := chattool.ReadTemplate(chattool.ReadTemplateOptions{
|
||||
DB: db,
|
||||
OwnerID: user.ID,
|
||||
AllowedTemplateIDs: func() map[uuid.UUID]bool { return map[uuid.UUID]bool{t1.ID: true} },
|
||||
})
|
||||
input := `{"template_id":"` + t1.ID.String() + `"}`
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c5", Name: "read_template", Input: input})
|
||||
require.NoError(t, err)
|
||||
require.False(t, resp.IsError)
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
tmplInfo := result["template"].(map[string]any)
|
||||
require.Equal(t, t1.ID.String(), tmplInfo["id"].(string))
|
||||
})
|
||||
|
||||
t.Run("Disallowed", func(t *testing.T) {
|
||||
tool := chattool.ReadTemplate(chattool.ReadTemplateOptions{
|
||||
DB: db,
|
||||
OwnerID: user.ID,
|
||||
AllowedTemplateIDs: func() map[uuid.UUID]bool { return map[uuid.UUID]bool{uuid.New(): true} },
|
||||
})
|
||||
input := `{"template_id":"` + t2.ID.String() + `"}`
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c6", Name: "read_template", Input: input})
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.IsError)
|
||||
require.Contains(t, resp.Content, "not found")
|
||||
})
|
||||
|
||||
t.Run("NoAllowlist", func(t *testing.T) {
|
||||
tool := chattool.ReadTemplate(chattool.ReadTemplateOptions{
|
||||
DB: db,
|
||||
OwnerID: user.ID,
|
||||
})
|
||||
input := `{"template_id":"` + t2.ID.String() + `"}`
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c7", Name: "read_template", Input: input})
|
||||
require.NoError(t, err)
|
||||
require.False(t, resp.IsError)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("CreateWorkspace", func(t *testing.T) {
|
||||
t.Run("Allowed", func(t *testing.T) {
|
||||
createCalled := false
|
||||
tool := chattool.CreateWorkspace(chattool.CreateWorkspaceOptions{
|
||||
DB: db,
|
||||
OwnerID: user.ID,
|
||||
AllowedTemplateIDs: func() map[uuid.UUID]bool { return map[uuid.UUID]bool{t1.ID: true} },
|
||||
CreateFn: func(_ context.Context, _ uuid.UUID, _ codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) {
|
||||
createCalled = true
|
||||
return codersdk.Workspace{}, nil
|
||||
},
|
||||
})
|
||||
input := `{"template_id":"` + t1.ID.String() + `"}`
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c8a", Name: "create_workspace", Input: input})
|
||||
require.NoError(t, err)
|
||||
require.True(t, createCalled, "CreateFn should be called for allowed template")
|
||||
// We don't assert resp.IsError here because CreateWorkspace
|
||||
// does additional work (asOwner, workspace lookup) that
|
||||
// depends on full RBAC setup. The key assertion is that
|
||||
// the allowlist gate passed and CreateFn was invoked.
|
||||
_ = resp
|
||||
})
|
||||
|
||||
t.Run("Disallowed", func(t *testing.T) {
|
||||
createCalled := false
|
||||
tool := chattool.CreateWorkspace(chattool.CreateWorkspaceOptions{
|
||||
DB: db,
|
||||
OwnerID: user.ID,
|
||||
AllowedTemplateIDs: func() map[uuid.UUID]bool { return map[uuid.UUID]bool{uuid.New(): true} },
|
||||
CreateFn: func(_ context.Context, _ uuid.UUID, _ codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) {
|
||||
createCalled = true
|
||||
t.Fatal("CreateFn should not be called for blocked template")
|
||||
return codersdk.Workspace{}, nil
|
||||
},
|
||||
})
|
||||
input := `{"template_id":"` + t1.ID.String() + `"}`
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c8", Name: "create_workspace", Input: input})
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.IsError)
|
||||
require.Contains(t, resp.Content, "template not available for chat workspaces")
|
||||
require.False(t, createCalled, "CreateFn should not be called for blocked template")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -14,8 +14,9 @@ import (
|
||||
|
||||
// ReadTemplateOptions configures the read_template tool.
|
||||
type ReadTemplateOptions struct {
|
||||
DB database.Store
|
||||
OwnerID uuid.UUID
|
||||
DB database.Store
|
||||
OwnerID uuid.UUID
|
||||
AllowedTemplateIDs func() map[uuid.UUID]bool
|
||||
}
|
||||
|
||||
type readTemplateArgs struct {
|
||||
@@ -48,6 +49,10 @@ func ReadTemplate(options ReadTemplateOptions) fantasy.AgentTool {
|
||||
), nil
|
||||
}
|
||||
|
||||
if !isTemplateAllowed(options.AllowedTemplateIDs, templateID) {
|
||||
return fantasy.NewTextErrorResponse("template not found"), nil
|
||||
}
|
||||
|
||||
ctx, err = asOwner(ctx, options.DB, options.OwnerID)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
|
||||
Reference in New Issue
Block a user