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:
Cian Johnston
2026-03-25 15:19:17 +00:00
committed by GitHub
parent c0ab22dc88
commit 796872f4de
23 changed files with 1045 additions and 10 deletions
+50 -4
View File
@@ -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,
+15
View File
@@ -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]
}
+5 -1
View File
@@ -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.
+12 -2
View File
@@ -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")
})
})
}
+7 -2
View File
@@ -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