mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: remove legacy chat template allowlist (#27515)
Relates to CODAGT-713 Depends on #27514 Removes the legacy deployment-wide allowlist now that the API and frontend use per-template `agents_allowed`: the experimental `/template-allowlist` routes, SDK methods and generated types, site config queries, frontend bindings, and the now-unused `xjson` utility. Migration `000563` deletes the obsolete `agents_template_allowlist` value. It's irreversible for deployments that configured an allowlist, which I think is fine, since `000562` already drops `agents_allowed` on the way down, and this release ships `000548` and `000555` with the same property. Two side effects of the model change worth writing down, both from #27514 rather than here. The value used to need `ActionRead` on `ResourceDeploymentConfig` to read and deployment config update to write. `AgentsAllowed` is now a plain field on the template response, readable by anyone who can read the template, and it's set with a template update, so org admins manage it themselves. That's the delegation we wanted, and it's tracked in the audit log. The rest of the stack adds `--agents-allowed` to the CLI and updates the platform controls docs.
This commit is contained in:
@@ -1398,8 +1398,6 @@ func New(options *Options) *API {
|
||||
r.Put("/debug-retention-days", api.putChatDebugRetentionDays)
|
||||
r.Get("/auto-archive-days", api.getChatAutoArchiveDays)
|
||||
r.Put("/auto-archive-days", api.putChatAutoArchiveDays)
|
||||
r.Get("/template-allowlist", api.getChatTemplateAllowlist)
|
||||
r.Put("/template-allowlist", api.putChatTemplateAllowlist)
|
||||
})
|
||||
// TODO(cian): place under /api/experimental/chats/config
|
||||
r.Route("/providers", func(r chi.Router) {
|
||||
|
||||
@@ -3555,17 +3555,6 @@ func (q *querier) GetChatSystemPromptConfig(ctx context.Context) (database.GetCh
|
||||
return q.db.GetChatSystemPromptConfig(ctx)
|
||||
}
|
||||
|
||||
// GetChatTemplateAllowlist requires deployment-config read permission,
|
||||
// unlike the peer getters (GetChatDesktopEnabled, etc.) which only
|
||||
// check actor presence. The allowlist is admin-configuration that
|
||||
// should not be readable by non-admin users via the HTTP API.
|
||||
func (q *querier) GetChatTemplateAllowlist(ctx context.Context) (string, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return q.db.GetChatTemplateAllowlist(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetChatTitleGenerationModelOverride(ctx context.Context) (string, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return "", err
|
||||
@@ -8914,13 +8903,6 @@ func (q *querier) UpsertChatSystemPrompt(ctx context.Context, value string) erro
|
||||
return q.db.UpsertChatSystemPrompt(ctx, value)
|
||||
}
|
||||
|
||||
func (q *querier) UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.UpsertChatTemplateAllowlist(ctx, templateAllowlist)
|
||||
}
|
||||
|
||||
func (q *querier) UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1158,10 +1158,6 @@ func (s *MethodTestSuite) TestChats() {
|
||||
dbm.EXPECT().GetChatPlanModeInstructions(gomock.Any()).Return("", nil).AnyTimes()
|
||||
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("GetChatTemplateAllowlist", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().GetChatTemplateAllowlist(gomock.Any()).Return("", nil).AnyTimes()
|
||||
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetChatWorkspaceTTL", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().GetChatWorkspaceTTL(gomock.Any()).Return("1h", nil).AnyTimes()
|
||||
check.Args().Asserts()
|
||||
@@ -1594,10 +1590,6 @@ func (s *MethodTestSuite) TestChats() {
|
||||
dbm.EXPECT().UpsertChatPlanModeInstructions(gomock.Any(), "").Return(nil).AnyTimes()
|
||||
check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("UpsertChatTemplateAllowlist", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().UpsertChatTemplateAllowlist(gomock.Any(), "").Return(nil).AnyTimes()
|
||||
check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("UpsertChatWorkspaceTTL", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().UpsertChatWorkspaceTTL(gomock.Any(), "1h").Return(nil).AnyTimes()
|
||||
check.Args("1h").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
|
||||
|
||||
-16
@@ -1769,14 +1769,6 @@ func (m queryMetricsStore) GetChatSystemPromptConfig(ctx context.Context) (datab
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetChatTemplateAllowlist(ctx context.Context) (string, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetChatTemplateAllowlist(ctx)
|
||||
m.queryLatencies.WithLabelValues("GetChatTemplateAllowlist").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatTemplateAllowlist").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetChatTitleGenerationModelOverride(ctx context.Context) (string, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetChatTitleGenerationModelOverride(ctx)
|
||||
@@ -6305,14 +6297,6 @@ func (m queryMetricsStore) UpsertChatSystemPrompt(ctx context.Context, value str
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UpsertChatTemplateAllowlist(ctx, templateAllowlist)
|
||||
m.queryLatencies.WithLabelValues("UpsertChatTemplateAllowlist").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatTemplateAllowlist").Inc()
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UpsertChatTitleGenerationModelOverride(ctx, value)
|
||||
|
||||
Generated
-29
@@ -3270,21 +3270,6 @@ func (mr *MockStoreMockRecorder) GetChatSystemPromptConfig(ctx any) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatSystemPromptConfig", reflect.TypeOf((*MockStore)(nil).GetChatSystemPromptConfig), ctx)
|
||||
}
|
||||
|
||||
// GetChatTemplateAllowlist mocks base method.
|
||||
func (m *MockStore) GetChatTemplateAllowlist(ctx context.Context) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetChatTemplateAllowlist", ctx)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetChatTemplateAllowlist indicates an expected call of GetChatTemplateAllowlist.
|
||||
func (mr *MockStoreMockRecorder) GetChatTemplateAllowlist(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatTemplateAllowlist", reflect.TypeOf((*MockStore)(nil).GetChatTemplateAllowlist), ctx)
|
||||
}
|
||||
|
||||
// GetChatTitleGenerationModelOverride mocks base method.
|
||||
func (m *MockStore) GetChatTitleGenerationModelOverride(ctx context.Context) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -11804,20 +11789,6 @@ func (mr *MockStoreMockRecorder) UpsertChatSystemPrompt(ctx, value any) *gomock.
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatSystemPrompt", reflect.TypeOf((*MockStore)(nil).UpsertChatSystemPrompt), ctx, value)
|
||||
}
|
||||
|
||||
// UpsertChatTemplateAllowlist mocks base method.
|
||||
func (m *MockStore) UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpsertChatTemplateAllowlist", ctx, templateAllowlist)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UpsertChatTemplateAllowlist indicates an expected call of UpsertChatTemplateAllowlist.
|
||||
func (mr *MockStoreMockRecorder) UpsertChatTemplateAllowlist(ctx, templateAllowlist any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatTemplateAllowlist", reflect.TypeOf((*MockStore)(nil).UpsertChatTemplateAllowlist), ctx, templateAllowlist)
|
||||
}
|
||||
|
||||
// UpsertChatTitleGenerationModelOverride mocks base method.
|
||||
func (m *MockStore) UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- No-op. The up migration deleted the only copy of the deployment-wide
|
||||
-- allowlist, so there is nothing left to restore.
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 000563 copied this value into templates.agents_allowed. Deleting the row
|
||||
-- makes the original list unrecoverable.
|
||||
DELETE FROM site_configs
|
||||
WHERE key = 'agents_template_allowlist';
|
||||
Generated
-4
@@ -507,9 +507,6 @@ type sqlcQuerier interface {
|
||||
// non-empty custom prompt implied opting out before the explicit toggle
|
||||
// existed.
|
||||
GetChatSystemPromptConfig(ctx context.Context) (GetChatSystemPromptConfigRow, error)
|
||||
// GetChatTemplateAllowlist returns the JSON-encoded template allowlist.
|
||||
// Returns an empty string when no allowlist has been configured (all templates allowed).
|
||||
GetChatTemplateAllowlist(ctx context.Context) (string, error)
|
||||
GetChatTitleGenerationModelOverride(ctx context.Context) (string, error)
|
||||
// Returns the concatenated text of each user-visible user prompt in a
|
||||
// chat, newest first. Used by the composer to populate the up/down
|
||||
@@ -1650,7 +1647,6 @@ type sqlcQuerier interface {
|
||||
UpsertChatPlanModeInstructions(ctx context.Context, value string) error
|
||||
UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error
|
||||
UpsertChatSystemPrompt(ctx context.Context, value string) error
|
||||
UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error
|
||||
UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error
|
||||
UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error
|
||||
// The default proxy is implied and not actually stored in the database.
|
||||
|
||||
Generated
-24
@@ -24825,20 +24825,6 @@ func (q *sqlQuerier) GetChatSystemPromptConfig(ctx context.Context) (GetChatSyst
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getChatTemplateAllowlist = `-- name: GetChatTemplateAllowlist :one
|
||||
SELECT
|
||||
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_template_allowlist'), '') :: text AS template_allowlist
|
||||
`
|
||||
|
||||
// GetChatTemplateAllowlist returns the JSON-encoded template allowlist.
|
||||
// Returns an empty string when no allowlist has been configured (all templates allowed).
|
||||
func (q *sqlQuerier) GetChatTemplateAllowlist(ctx context.Context) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getChatTemplateAllowlist)
|
||||
var template_allowlist string
|
||||
err := row.Scan(&template_allowlist)
|
||||
return template_allowlist, err
|
||||
}
|
||||
|
||||
const getChatTitleGenerationModelOverride = `-- name: GetChatTitleGenerationModelOverride :one
|
||||
SELECT
|
||||
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_title_generation_model_override'), '') :: text AS model_config_id
|
||||
@@ -25265,16 +25251,6 @@ func (q *sqlQuerier) UpsertChatSystemPrompt(ctx context.Context, value string) e
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertChatTemplateAllowlist = `-- name: UpsertChatTemplateAllowlist :exec
|
||||
INSERT INTO site_configs (key, value) VALUES ('agents_template_allowlist', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_template_allowlist'
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertChatTemplateAllowlist, templateAllowlist)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertChatTitleGenerationModelOverride = `-- name: UpsertChatTitleGenerationModelOverride :exec
|
||||
INSERT INTO site_configs (key, value) VALUES ('agents_chat_title_generation_model_override', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_title_generation_model_override'
|
||||
|
||||
@@ -313,12 +313,6 @@ SET value = CASE
|
||||
END
|
||||
WHERE site_configs.key = 'agents_chat_personal_model_overrides_enabled';
|
||||
|
||||
-- GetChatTemplateAllowlist returns the JSON-encoded template allowlist.
|
||||
-- Returns an empty string when no allowlist has been configured (all templates allowed).
|
||||
-- name: GetChatTemplateAllowlist :one
|
||||
SELECT
|
||||
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_template_allowlist'), '') :: text AS template_allowlist;
|
||||
|
||||
-- GetChatIncludeDefaultSystemPrompt preserves the legacy default
|
||||
-- for deployments created before the explicit include-default toggle.
|
||||
-- When the toggle is unset, a non-empty custom prompt implies false;
|
||||
@@ -360,10 +354,6 @@ SELECT
|
||||
'0s'
|
||||
)::text AS workspace_ttl;
|
||||
|
||||
-- name: UpsertChatTemplateAllowlist :exec
|
||||
INSERT INTO site_configs (key, value) VALUES ('agents_template_allowlist', @template_allowlist)
|
||||
ON CONFLICT (key) DO UPDATE SET value = @template_allowlist WHERE site_configs.key = 'agents_template_allowlist';
|
||||
|
||||
-- name: UpsertChatWorkspaceTTL :exec
|
||||
INSERT INTO site_configs (key, value)
|
||||
VALUES ('agents_workspace_ttl', @workspace_ttl::text)
|
||||
|
||||
@@ -42,7 +42,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/searchquery"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/util/xjson"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps"
|
||||
"github.com/coder/coder/v2/coderd/wsbuilder"
|
||||
"github.com/coder/coder/v2/coderd/x/agenthooks/dispatch"
|
||||
@@ -5593,140 +5592,6 @@ func (api *API) putChatAutoArchiveDays(rw http.ResponseWriter, r *http.Request)
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
//
|
||||
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
|
||||
func (api *API) getChatTemplateAllowlist(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
raw, err := api.Database.GetChatTemplateAllowlist(ctx)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching chat template allowlist.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
parsed, parseErr := xjson.ParseUUIDList(raw)
|
||||
if parseErr != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Stored template allowlist is corrupt.",
|
||||
Detail: parseErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
ids := make([]string, len(parsed))
|
||||
for i, id := range parsed {
|
||||
ids[i] = id.String()
|
||||
}
|
||||
resp := codersdk.ChatTemplateAllowlist{
|
||||
TemplateIDs: ids,
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
func (api *API) putChatTemplateAllowlist(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
var req codersdk.ChatTemplateAllowlist
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate all entries are valid UUIDs and deduplicate.
|
||||
seen := make(map[string]struct{}, len(req.TemplateIDs))
|
||||
deduped := make([]string, 0, len(req.TemplateIDs))
|
||||
for _, id := range req.TemplateIDs {
|
||||
parsed, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid template ID in allowlist.",
|
||||
Detail: fmt.Sprintf("%q is not a valid UUID.", id),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Canonicalize to lowercase so deduplication is
|
||||
// case-insensitive and stored values are consistent.
|
||||
canonical := parsed.String()
|
||||
if _, ok := seen[canonical]; !ok {
|
||||
seen[canonical] = struct{}{}
|
||||
deduped = append(deduped, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to UUIDs for the database query.
|
||||
parsedUUIDs := make([]uuid.UUID, len(deduped))
|
||||
for i, s := range deduped {
|
||||
// Already validated above, safe to ignore error.
|
||||
parsedUUIDs[i], _ = uuid.Parse(s)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(deduped)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error encoding template allowlist.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = api.Database.InTx(func(tx database.Store) error {
|
||||
// Verify all IDs refer to existing, non-deprecated templates
|
||||
// in a single query.
|
||||
if len(parsedUUIDs) > 0 {
|
||||
found, err := tx.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{
|
||||
IDs: parsedUUIDs,
|
||||
Deprecated: sql.NullBool{
|
||||
Bool: false,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch templates: %w", err)
|
||||
}
|
||||
if len(found) != len(parsedUUIDs) {
|
||||
foundSet := make(map[uuid.UUID]struct{}, len(found))
|
||||
for _, t := range found {
|
||||
foundSet[t.ID] = struct{}{}
|
||||
}
|
||||
var missing []string
|
||||
for _, id := range parsedUUIDs {
|
||||
if _, ok := foundSet[id]; !ok {
|
||||
missing = append(missing, id.String())
|
||||
}
|
||||
}
|
||||
return xerrors.Errorf("templates not found or deprecated: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
}
|
||||
return tx.UpsertChatTemplateAllowlist(ctx, string(raw))
|
||||
}, nil)
|
||||
if err != nil {
|
||||
// If the error mentions "not found or deprecated", it's a
|
||||
// validation failure, not an internal error.
|
||||
if strings.Contains(err.Error(), "not found or deprecated") {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "One or more templates not found or deprecated.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error updating chat template allowlist.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
//
|
||||
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
|
||||
|
||||
@@ -14968,134 +14968,6 @@ func TestUserChatCompactionThresholds(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:tparallel // Subtests share a single coderdtest instance and run sequentially.
|
||||
func TestChatTemplateAllowlist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Shared setup: one coderdtest instance with two real templates.
|
||||
// Subtests that need valid template IDs use these.
|
||||
client, store := newChatClientWithDatabase(t)
|
||||
admin := coderdtest.CreateFirstUser(t, client.Client)
|
||||
tmpl1 := dbgen.Template(t, store, database.Template{
|
||||
OrganizationID: admin.OrganizationID,
|
||||
CreatedBy: admin.UserID,
|
||||
})
|
||||
tmpl2 := dbgen.Template(t, store, database.Template{
|
||||
OrganizationID: admin.OrganizationID,
|
||||
CreatedBy: admin.UserID,
|
||||
})
|
||||
deprecatedTmpl := dbgen.Template(t, store, database.Template{
|
||||
OrganizationID: admin.OrganizationID,
|
||||
CreatedBy: admin.UserID,
|
||||
})
|
||||
//nolint:gocritic // Owner context needed to deprecate the template in test setup.
|
||||
ownerRoles, err := rbac.RoleIdentifiers{rbac.RoleOwner()}.Expand()
|
||||
require.NoError(t, err)
|
||||
err = store.UpdateTemplateAccessControlByID(dbauthz.As(context.Background(), rbac.Subject{
|
||||
ID: "owner",
|
||||
Roles: rbac.Roles(ownerRoles),
|
||||
Scope: rbac.ExpandableScope(rbac.ScopeAll),
|
||||
}), database.UpdateTemplateAccessControlByIDParams{
|
||||
ID: deprecatedTmpl.ID,
|
||||
Deprecated: "this template is deprecated",
|
||||
})
|
||||
require.NoError(t, err, "deprecate template")
|
||||
|
||||
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
|
||||
t.Run("ReturnsEmptyWhenUnset", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
resp, err := client.GetChatTemplateAllowlist(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, resp.TemplateIDs)
|
||||
})
|
||||
|
||||
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
|
||||
t.Run("AdminCanSet", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
ids := []string{tmpl1.ID.String(), tmpl2.ID.String()}
|
||||
err := client.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{TemplateIDs: ids})
|
||||
require.NoError(t, err)
|
||||
resp, err := client.GetChatTemplateAllowlist(ctx)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, ids, resp.TemplateIDs)
|
||||
})
|
||||
|
||||
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
|
||||
t.Run("AdminCanClear", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := client.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{TemplateIDs: []string{}})
|
||||
require.NoError(t, err)
|
||||
resp, err := client.GetChatTemplateAllowlist(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, resp.TemplateIDs)
|
||||
})
|
||||
|
||||
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
|
||||
t.Run("NonAdminReadFails", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, admin.OrganizationID)
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
_, err := memberClient.GetChatTemplateAllowlist(ctx)
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
})
|
||||
|
||||
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
|
||||
t.Run("NonAdminWriteFails", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, admin.OrganizationID)
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
// Uses a random UUID — hits 404 before template validation.
|
||||
err := memberClient.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{TemplateIDs: []string{uuid.NewString()}})
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
})
|
||||
|
||||
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
|
||||
t.Run("UnauthenticatedFails", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
anonClient := codersdk.NewExperimentalClient(codersdk.New(client.URL))
|
||||
// Uses a random UUID — hits 401 before template validation.
|
||||
err := anonClient.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{TemplateIDs: []string{uuid.NewString()}})
|
||||
requireSDKError(t, err, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
|
||||
t.Run("InvalidUUIDRejected", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := client.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{TemplateIDs: []string{"not-a-uuid"}})
|
||||
requireSDKError(t, err, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
|
||||
t.Run("NonexistentTemplateRejected", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := client.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{TemplateIDs: []string{uuid.NewString()}})
|
||||
requireSDKError(t, err, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
|
||||
t.Run("DeprecatedTemplateRejected", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := client.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{
|
||||
TemplateIDs: []string{deprecatedTmpl.ID.String()},
|
||||
})
|
||||
requireSDKError(t, err, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
|
||||
t.Run("DeduplicatesIDs", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
id := tmpl1.ID.String()
|
||||
err := client.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{
|
||||
TemplateIDs: []string{id, id, id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp, err := client.GetChatTemplateAllowlist(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.TemplateIDs, 1)
|
||||
require.Equal(t, id, resp.TemplateIDs[0])
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetChatsByWorkspace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package xjson
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// ParseUUIDList parses a JSON-encoded array of UUID strings
|
||||
// (e.g. `["uuid1","uuid2"]`) and returns the corresponding
|
||||
// slice of uuid.UUID values. An empty input (including
|
||||
// whitespace-only) returns an empty (non-nil) slice.
|
||||
func ParseUUIDList(raw string) ([]uuid.UUID, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return []uuid.UUID{}, nil
|
||||
}
|
||||
|
||||
var strs []string
|
||||
if err := json.Unmarshal([]byte(raw), &strs); err != nil {
|
||||
return nil, xerrors.Errorf("unmarshal uuid list: %w", err)
|
||||
}
|
||||
|
||||
ids := make([]uuid.UUID, 0, len(strs))
|
||||
for _, s := range strs {
|
||||
id, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse uuid %q: %w", s, err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package xjson_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/util/xjson"
|
||||
)
|
||||
|
||||
func TestParseUUIDList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := uuid.MustParse("c7c6686d-a93c-4df2-bef9-5f837e9a33d5")
|
||||
b := uuid.MustParse("8f3b3e0b-2c3f-46a5-a365-fd5b62bd8818")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []uuid.UUID
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "EmptyString",
|
||||
input: "",
|
||||
want: []uuid.UUID{},
|
||||
},
|
||||
{
|
||||
name: "JSONNull",
|
||||
input: "null",
|
||||
want: []uuid.UUID{},
|
||||
},
|
||||
{
|
||||
name: "WhitespaceOnly",
|
||||
input: " \n\t ",
|
||||
want: []uuid.UUID{},
|
||||
},
|
||||
{
|
||||
name: "ValidUUIDs",
|
||||
input: `["c7c6686d-a93c-4df2-bef9-5f837e9a33d5","8f3b3e0b-2c3f-46a5-a365-fd5b62bd8818"]`,
|
||||
want: []uuid.UUID{a, b},
|
||||
},
|
||||
{
|
||||
name: "InvalidJSON",
|
||||
input: "not json at all",
|
||||
wantErr: "unmarshal uuid list",
|
||||
},
|
||||
{
|
||||
name: "InvalidUUID",
|
||||
input: `["not-a-uuid"]`,
|
||||
wantErr: "parse uuid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := xjson.ParseUUIDList(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1205,13 +1205,6 @@ func ParseChatWorkspaceTTL(s string) (time.Duration, error) {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// ChatTemplateAllowlist is the request and response body for the
|
||||
// chat template allowlist configuration endpoint. An empty list
|
||||
// means all templates are allowed.
|
||||
type ChatTemplateAllowlist struct {
|
||||
TemplateIDs []string `json:"template_ids"`
|
||||
}
|
||||
|
||||
// ChatProviderConfigSource describes how a provider entry is sourced.
|
||||
type ChatProviderConfigSource string
|
||||
|
||||
@@ -2574,33 +2567,6 @@ func (c *ExperimentalClient) UpdateChatAutoArchiveDays(ctx context.Context, req
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetChatTemplateAllowlist returns the deployment-wide chat template allowlist.
|
||||
func (c *ExperimentalClient) GetChatTemplateAllowlist(ctx context.Context) (ChatTemplateAllowlist, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/template-allowlist", nil)
|
||||
if err != nil {
|
||||
return ChatTemplateAllowlist{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return ChatTemplateAllowlist{}, ReadBodyAsError(res)
|
||||
}
|
||||
var resp ChatTemplateAllowlist
|
||||
return resp, ReadBodyAsJSON(res, &resp)
|
||||
}
|
||||
|
||||
// UpdateChatTemplateAllowlist updates the deployment-wide chat template allowlist.
|
||||
func (c *ExperimentalClient) UpdateChatTemplateAllowlist(ctx context.Context, req ChatTemplateAllowlist) error {
|
||||
res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/template-allowlist", req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserChatCustomPrompt updates the user's custom chat prompt.
|
||||
func (c *ExperimentalClient) UpdateUserChatCustomPrompt(ctx context.Context, req UserChatCustomPrompt) (UserChatCustomPrompt, error) {
|
||||
res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/user-prompt", req)
|
||||
|
||||
@@ -3695,14 +3695,6 @@ class ExperimentalApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getChatTemplateAllowlist =
|
||||
async (): Promise<TypesGen.ChatTemplateAllowlist> => {
|
||||
const response = await this.axios.get<TypesGen.ChatTemplateAllowlist>(
|
||||
"/api/experimental/chats/config/template-allowlist",
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
updateChatWorkspaceTTL = async (
|
||||
req: TypesGen.UpdateChatWorkspaceTTLRequest,
|
||||
): Promise<void> => {
|
||||
@@ -3759,15 +3751,6 @@ class ExperimentalApiMethods {
|
||||
);
|
||||
};
|
||||
|
||||
updateChatTemplateAllowlist = async (
|
||||
req: TypesGen.ChatTemplateAllowlist,
|
||||
): Promise<void> => {
|
||||
await this.axios.put(
|
||||
"/api/experimental/chats/config/template-allowlist",
|
||||
req,
|
||||
);
|
||||
};
|
||||
|
||||
getUserChatCustomPrompt =
|
||||
async (): Promise<TypesGen.UserChatCustomPrompt> => {
|
||||
const response = await this.axios.get<TypesGen.UserChatCustomPrompt>(
|
||||
|
||||
Generated
-10
@@ -3433,16 +3433,6 @@ export interface ChatSystemPromptResponse {
|
||||
readonly default_system_prompt: string;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* ChatTemplateAllowlist is the request and response body for the
|
||||
* chat template allowlist configuration endpoint. An empty list
|
||||
* means all templates are allowed.
|
||||
*/
|
||||
export interface ChatTemplateAllowlist {
|
||||
readonly template_ids: readonly string[];
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
export interface ChatTextPart {
|
||||
readonly type: "text";
|
||||
|
||||
Reference in New Issue
Block a user