From eaa2e3a8846544c7ae46c29b8b70093659abd1af Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 14 Jul 2026 04:51:47 +0000 Subject: [PATCH 1/6] feat(coderd/database): add agents_allowed to templates --- coderd/database/dump.sql | 6 +- .../000552_template_agents_allowed.down.sql | 17 ++ .../000552_template_agents_allowed.up.sql | 60 +++++ coderd/database/migrations/migrate_test.go | 229 ++++++++++++++++++ coderd/database/modelqueries.go | 2 + coderd/database/models.go | 3 + coderd/database/querier_test.go | 88 +++++++ coderd/database/queries.sql.go | 34 ++- coderd/database/queries/templates.sql | 6 + docs/admin/security/audit-logs.md | 2 +- enterprise/audit/table.go | 1 + 11 files changed, 435 insertions(+), 13 deletions(-) create mode 100644 coderd/database/migrations/000552_template_agents_allowed.down.sql create mode 100644 coderd/database/migrations/000552_template_agents_allowed.up.sql diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 8af6db94bab..c1e9c6e438f 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -3461,7 +3461,8 @@ CREATE TABLE templates ( use_classic_parameter_flow boolean DEFAULT false NOT NULL, cors_behavior cors_behavior DEFAULT 'simple'::cors_behavior NOT NULL, disable_module_cache boolean DEFAULT false NOT NULL, - time_til_autostop_notify bigint DEFAULT 0 NOT NULL + time_til_autostop_notify bigint DEFAULT 0 NOT NULL, + agents_allowed boolean DEFAULT true NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.'; @@ -3486,6 +3487,8 @@ COMMENT ON COLUMN templates.use_classic_parameter_flow IS 'Determines whether to COMMENT ON COLUMN templates.time_til_autostop_notify IS 'How long before the workspace autostop deadline to send a reminder notification, in nanoseconds. 0 disables the notification.'; +COMMENT ON COLUMN templates.agents_allowed IS 'Whether Coder Agents can use this template to create workspaces.'; + CREATE VIEW template_with_names AS SELECT templates.id, templates.created_at, @@ -3519,6 +3522,7 @@ CREATE VIEW template_with_names AS templates.cors_behavior, templates.disable_module_cache, templates.time_til_autostop_notify, + templates.agents_allowed, COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, COALESCE(visible_users.username, ''::text) AS created_by_username, COALESCE(visible_users.name, ''::text) AS created_by_name, diff --git a/coderd/database/migrations/000552_template_agents_allowed.down.sql b/coderd/database/migrations/000552_template_agents_allowed.down.sql new file mode 100644 index 00000000000..a7131843088 --- /dev/null +++ b/coderd/database/migrations/000552_template_agents_allowed.down.sql @@ -0,0 +1,17 @@ +DROP VIEW template_with_names; + +ALTER TABLE templates DROP COLUMN agents_allowed; + +CREATE VIEW template_with_names AS +SELECT templates.*, + COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, + COALESCE(visible_users.username, ''::text) AS created_by_username, + COALESCE(visible_users.name, ''::text) AS created_by_name, + COALESCE(organizations.name, ''::text) AS organization_name, + COALESCE(organizations.display_name, ''::text) AS organization_display_name, + COALESCE(organizations.icon, ''::text) AS organization_icon +FROM ((templates + LEFT JOIN visible_users ON ((templates.created_by = visible_users.id))) + LEFT JOIN organizations ON ((templates.organization_id = organizations.id))); + +COMMENT ON VIEW template_with_names IS 'Joins in the display name information such as username, avatar, and organization name.'; diff --git a/coderd/database/migrations/000552_template_agents_allowed.up.sql b/coderd/database/migrations/000552_template_agents_allowed.up.sql new file mode 100644 index 00000000000..6dfa0724a47 --- /dev/null +++ b/coderd/database/migrations/000552_template_agents_allowed.up.sql @@ -0,0 +1,60 @@ +ALTER TABLE templates ADD COLUMN agents_allowed boolean DEFAULT true NOT NULL; + +COMMENT ON COLUMN templates.agents_allowed IS 'Whether Coder Agents can use this template to create workspaces.'; + +DO $$ +DECLARE + raw text; + parsed jsonb; + parsed_ids uuid[]; +BEGIN + SELECT value INTO raw + FROM site_configs + WHERE key = 'agents_template_allowlist'; + + IF raw IS NULL OR btrim(raw) = '' THEN + RETURN; + END IF; + + BEGIN + parsed := raw::jsonb; + IF jsonb_typeof(parsed) <> 'array' THEN + RAISE EXCEPTION 'value is not a JSON array'; + END IF; + IF jsonb_array_length(parsed) = 0 THEN + RETURN; + END IF; + + SELECT array_agg(entry::uuid) + INTO parsed_ids + FROM jsonb_array_elements_text(parsed) AS entries(entry); + + IF array_position(parsed_ids, NULL) IS NOT NULL THEN + RAISE EXCEPTION 'contains a null template ID'; + END IF; + EXCEPTION WHEN others THEN + RAISE WARNING 'agents_template_allowlist is corrupt (%); leaving all templates allowed', SQLERRM; + RETURN; + END; + + -- A valid nonempty list allows matching existing templates only. Missing, + -- empty, or corrupt data leaves templates allowed. + UPDATE templates + SET agents_allowed = (id = ANY(parsed_ids)); +END $$; + +DROP VIEW template_with_names; + +CREATE VIEW template_with_names AS +SELECT templates.*, + COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, + COALESCE(visible_users.username, ''::text) AS created_by_username, + COALESCE(visible_users.name, ''::text) AS created_by_name, + COALESCE(organizations.name, ''::text) AS organization_name, + COALESCE(organizations.display_name, ''::text) AS organization_display_name, + COALESCE(organizations.icon, ''::text) AS organization_icon +FROM ((templates + LEFT JOIN visible_users ON ((templates.created_by = visible_users.id))) + LEFT JOIN organizations ON ((templates.organization_id = organizations.id))); + +COMMENT ON VIEW template_with_names IS 'Joins in the display name information such as username, avatar, and organization name.'; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index b508d5d1e94..a339eaadef9 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -1865,6 +1865,235 @@ func TestMigration000558AuditOAuth2ProviderSettingsEnumInSingleTxn(t *testing.T) require.NoError(t, err) } +//nolint:tparallel,paralleltest // Subtests share one database and exercise sequential migration state. +func TestMigration000552TemplateAgentsAllowedBackfill(t *testing.T) { + t.Parallel() + + sqlDB, ctx, orgID, userID, templateIDs := setupMigration000552Templates(t) + upSQL, err := os.ReadFile("000552_template_agents_allowed.up.sql") + require.NoError(t, err) + downSQL, err := os.ReadFile("000552_template_agents_allowed.down.sql") + require.NoError(t, err) + + staleID := uuid.New() + tests := []struct { + name string + value string + present bool + checkPostMigrationDefault bool + want map[uuid.UUID]bool + }{ + { + name: "valid nonempty list", + value: fmt.Sprintf(`[%q]`, templateIDs[0]), + present: true, + checkPostMigrationDefault: true, + want: map[uuid.UUID]bool{ + templateIDs[0]: true, + templateIDs[1]: false, + }, + }, + { + name: "stale template ID", + value: fmt.Sprintf(`[%q,%q]`, templateIDs[0], staleID), + present: true, + want: map[uuid.UUID]bool{ + templateIDs[0]: true, + templateIDs[1]: false, + }, + }, + { + name: "missing", + want: map[uuid.UUID]bool{ + templateIDs[0]: true, + templateIDs[1]: true, + }, + }, + { + name: "empty string", + value: "", + present: true, + want: map[uuid.UUID]bool{ + templateIDs[0]: true, + templateIDs[1]: true, + }, + }, + { + name: "invalid JSON", + value: "{", + present: true, + want: map[uuid.UUID]bool{ + templateIDs[0]: true, + templateIDs[1]: true, + }, + }, + { + name: "JSON object", + value: `{}`, + present: true, + want: map[uuid.UUID]bool{ + templateIDs[0]: true, + templateIDs[1]: true, + }, + }, + { + name: "JSON scalar", + value: `"value"`, + present: true, + want: map[uuid.UUID]bool{ + templateIDs[0]: true, + templateIDs[1]: true, + }, + }, + { + name: "invalid UUID element", + value: `["not-a-uuid"]`, + present: true, + want: map[uuid.UUID]bool{ + templateIDs[0]: true, + templateIDs[1]: true, + }, + }, + { + name: "null element", + value: `[null]`, + present: true, + want: map[uuid.UUID]bool{ + templateIDs[0]: true, + templateIDs[1]: true, + }, + }, + { + name: "mixed valid and invalid elements", + value: fmt.Sprintf(`[%q,"not-a-uuid"]`, templateIDs[0]), + present: true, + want: map[uuid.UUID]bool{ + templateIDs[0]: true, + templateIDs[1]: true, + }, + }, + { + name: "empty array", + value: "[]", + present: true, + want: map[uuid.UUID]bool{ + templateIDs[0]: true, + templateIDs[1]: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := sqlDB.ExecContext(ctx, `DELETE FROM site_configs WHERE key = 'agents_template_allowlist'`) + require.NoError(t, err) + if tt.present { + _, err = sqlDB.ExecContext(ctx, `INSERT INTO site_configs (key, value) VALUES ('agents_template_allowlist', $1)`, tt.value) + require.NoError(t, err) + } + + _, err = sqlDB.ExecContext(ctx, string(upSQL)) + require.NoError(t, err) + t.Cleanup(func() { + _, err := sqlDB.ExecContext(ctx, string(downSQL)) + require.NoError(t, err) + }) + + rows, err := sqlDB.QueryContext(ctx, `SELECT id, agents_allowed FROM templates`) + require.NoError(t, err) + got := make(map[uuid.UUID]bool, len(templateIDs)) + for rows.Next() { + var id uuid.UUID + var agentsAllowed bool + require.NoError(t, rows.Scan(&id, &agentsAllowed)) + got[id] = agentsAllowed + } + require.NoError(t, rows.Close()) + require.NoError(t, rows.Err()) + require.Equal(t, tt.want, got) + + var stored string + err = sqlDB.QueryRowContext(ctx, `SELECT value FROM site_configs WHERE key = 'agents_template_allowlist'`).Scan(&stored) + if tt.present { + require.NoError(t, err) + require.Equal(t, tt.value, stored) + } else { + require.ErrorIs(t, err, sql.ErrNoRows) + } + + if tt.checkPostMigrationDefault { + newTemplateID := uuid.New() + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO templates (id, organization_id, name, created_at, updated_at, provisioner, active_version_id, created_by) + VALUES ($1, $2, $3, NOW(), NOW(), 'terraform', $4, $5) + `, newTemplateID, orgID, "post-migration-template", uuid.New(), userID) + require.NoError(t, err) + t.Cleanup(func() { + _, err := sqlDB.ExecContext(ctx, `DELETE FROM templates WHERE id = $1`, newTemplateID) + require.NoError(t, err) + }) + + var agentsAllowed bool + err = sqlDB.QueryRowContext(ctx, `SELECT agents_allowed FROM template_with_names WHERE id = $1`, newTemplateID).Scan(&agentsAllowed) + require.NoError(t, err) + require.True(t, agentsAllowed) + } + }) + } +} + +func setupMigration000552Templates(t *testing.T) ( + sqlDB *sql.DB, + ctx context.Context, + orgID uuid.UUID, + userID uuid.UUID, + templateIDs []uuid.UUID, +) { + t.Helper() + + const migrationVersion = 552 + + sqlDB = testSQLDB(t) + next, err := migrations.Stepper(sqlDB) + require.NoError(t, err) + for { + version, more, err := next() + require.NoError(t, err) + if !more { + t.Fatalf("migration %d not found", migrationVersion) + } + if version == migrationVersion-1 { + break + } + } + + ctx = testutil.Context(t, testutil.WaitSuperLong) + now := time.Now().UTC().Truncate(time.Microsecond) + orgID = uuid.New() + userID = uuid.New() + templateIDs = []uuid.UUID{uuid.New(), uuid.New()} + + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO organizations (id, name, display_name, description, created_at, updated_at, default_org_member_roles) + VALUES ($1, $2, $3, $4, $5, $5, '{}') + `, orgID, "agents-allowed-org", "Agents Allowed Org", "Migration test", now) + require.NoError(t, err) + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type) + VALUES ($1, $2, $3, $4, $5, $5, 'active', '{}', 'password') + `, userID, "agents-allowed-user", "agents-allowed@example.com", []byte{}, now) + require.NoError(t, err) + for i, templateID := range templateIDs { + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO templates (id, organization_id, name, created_at, updated_at, provisioner, active_version_id, created_by) + VALUES ($1, $2, $3, $4, $4, 'terraform', $5, $6) + `, templateID, orgID, fmt.Sprintf("agents-allowed-template-%d", i), now, uuid.New(), userID) + require.NoError(t, err) + } + + return sqlDB, ctx, orgID, userID, templateIDs +} + func TestMigration000498SoftDeleteStaleWorkspaceAgents(t *testing.T) { t.Parallel() diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index e7323bd6504..79f1d91095e 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -86,6 +86,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate pq.Array(arg.IDs), arg.Deprecated, arg.HasAITask, + arg.AgentsAllowed, arg.AuthorID, arg.AuthorUsername, arg.HasExternalAgent, @@ -130,6 +131,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.CorsBehavior, &i.DisableModuleCache, &i.TimeTilAutostopNotify, + &i.AgentsAllowed, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, diff --git a/coderd/database/models.go b/coderd/database/models.go index 6be9b7d19c9..954a701a27b 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5910,6 +5910,7 @@ type Template struct { CorsBehavior CorsBehavior `db:"cors_behavior" json:"cors_behavior"` DisableModuleCache bool `db:"disable_module_cache" json:"disable_module_cache"` TimeTilAutostopNotify int64 `db:"time_til_autostop_notify" json:"time_til_autostop_notify"` + AgentsAllowed bool `db:"agents_allowed" json:"agents_allowed"` CreatedByAvatarURL string `db:"created_by_avatar_url" json:"created_by_avatar_url"` CreatedByUsername string `db:"created_by_username" json:"created_by_username"` CreatedByName string `db:"created_by_name" json:"created_by_name"` @@ -5962,6 +5963,8 @@ type TemplateTable struct { DisableModuleCache bool `db:"disable_module_cache" json:"disable_module_cache"` // How long before the workspace autostop deadline to send a reminder notification, in nanoseconds. 0 disables the notification. TimeTilAutostopNotify int64 `db:"time_til_autostop_notify" json:"time_til_autostop_notify"` + // Whether Coder Agents can use this template to create workspaces. + AgentsAllowed bool `db:"agents_allowed" json:"agents_allowed"` } // Records aggregated usage statistics for templates/users. All usage is rounded up to the nearest minute. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 52ed5fa13a4..ac866e3921c 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -890,6 +890,94 @@ func TestGetWorkspaceAgentUsageStats(t *testing.T) { }) } +//nolint:tparallel,paralleltest // Subtests share one database seeded by the parent test. +func TestGetTemplatesWithAgentsAllowedFilter(t *testing.T) { + t.Parallel() + + db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + allowed := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + require.True(t, allowed.AgentsAllowed) + blocked := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + _, err := sqlDB.ExecContext(ctx, `UPDATE templates SET agents_allowed = false WHERE id = $1`, blocked.ID) + require.NoError(t, err) + + tests := []struct { + name string + value sql.NullBool + want []uuid.UUID + }{ + { + name: "unset", + want: []uuid.UUID{allowed.ID, blocked.ID}, + }, + { + name: "allowed", + value: sql.NullBool{Bool: true, Valid: true}, + want: []uuid.UUID{allowed.ID}, + }, + { + name: "blocked", + value: sql.NullBool{Bool: false, Valid: true}, + want: []uuid.UUID{blocked.ID}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := db.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ + Deleted: false, + OrganizationID: org.ID, + AgentsAllowed: tt.value, + }) + require.NoError(t, err) + gotIDs := make([]uuid.UUID, 0, len(got)) + for _, template := range got { + gotIDs = append(gotIDs, template.ID) + } + require.ElementsMatch(t, tt.want, gotIDs) + }) + } + + byID, err := db.GetTemplateByID(ctx, blocked.ID) + require.NoError(t, err) + require.False(t, byID.AgentsAllowed) + + all, err := db.GetTemplates(ctx) + require.NoError(t, err) + require.Len(t, all, 2) + for _, template := range all { + if template.ID == blocked.ID { + require.False(t, template.AgentsAllowed) + } + } + + prepared, err := (&coderdtest.FakeAuthorizer{}).Prepare( + ctx, + rbac.Subject{}, + policy.ActionRead, + rbac.ResourceTemplate.Type, + ) + require.NoError(t, err) + authorized, err := db.GetAuthorizedTemplates(ctx, database.GetTemplatesWithFilterParams{ + Deleted: false, + OrganizationID: org.ID, + AgentsAllowed: sql.NullBool{Bool: false, Valid: true}, + }, prepared) + require.NoError(t, err) + require.Len(t, authorized, 1) + require.Equal(t, blocked.ID, authorized[0].ID) + require.False(t, authorized[0].AgentsAllowed) +} + func TestGetWorkspaceAgentUsageStatsAndLabels(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 28204652549..a9d5f87ae6f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -26718,7 +26718,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, templateID const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, time_til_autostop_notify, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, time_til_autostop_notify, agents_allowed, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon FROM template_with_names WHERE @@ -26763,6 +26763,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.CorsBehavior, &i.DisableModuleCache, &i.TimeTilAutostopNotify, + &i.AgentsAllowed, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -26775,7 +26776,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, time_til_autostop_notify, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, time_til_autostop_notify, agents_allowed, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon FROM template_with_names AS templates WHERE @@ -26828,6 +26829,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.CorsBehavior, &i.DisableModuleCache, &i.TimeTilAutostopNotify, + &i.AgentsAllowed, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -26839,7 +26841,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, time_til_autostop_notify, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon FROM template_with_names AS templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, time_til_autostop_notify, agents_allowed, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon FROM template_with_names AS templates ORDER BY (name, id) ASC ` @@ -26885,6 +26887,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.CorsBehavior, &i.DisableModuleCache, &i.TimeTilAutostopNotify, + &i.AgentsAllowed, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -26907,7 +26910,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - t.id, t.created_at, t.updated_at, t.organization_id, t.deleted, t.name, t.provisioner, t.active_version_id, t.description, t.default_ttl, t.created_by, t.icon, t.user_acl, t.group_acl, t.display_name, t.allow_user_cancel_workspace_jobs, t.allow_user_autostart, t.allow_user_autostop, t.failure_ttl, t.time_til_dormant, t.time_til_dormant_autodelete, t.autostop_requirement_days_of_week, t.autostop_requirement_weeks, t.autostart_block_days_of_week, t.require_active_version, t.deprecated, t.activity_bump, t.max_port_sharing_level, t.use_classic_parameter_flow, t.cors_behavior, t.disable_module_cache, t.time_til_autostop_notify, t.created_by_avatar_url, t.created_by_username, t.created_by_name, t.organization_name, t.organization_display_name, t.organization_icon + t.id, t.created_at, t.updated_at, t.organization_id, t.deleted, t.name, t.provisioner, t.active_version_id, t.description, t.default_ttl, t.created_by, t.icon, t.user_acl, t.group_acl, t.display_name, t.allow_user_cancel_workspace_jobs, t.allow_user_autostart, t.allow_user_autostop, t.failure_ttl, t.time_til_dormant, t.time_til_dormant_autodelete, t.autostop_requirement_days_of_week, t.autostop_requirement_weeks, t.autostart_block_days_of_week, t.require_active_version, t.deprecated, t.activity_bump, t.max_port_sharing_level, t.use_classic_parameter_flow, t.cors_behavior, t.disable_module_cache, t.time_til_autostop_notify, t.agents_allowed, t.created_by_avatar_url, t.created_by_username, t.created_by_name, t.organization_name, t.organization_display_name, t.organization_icon FROM template_with_names AS t LEFT JOIN @@ -26974,23 +26977,29 @@ WHERE tv.has_ai_task = $9 :: boolean ELSE true END + -- Filter by agents_allowed + AND CASE + WHEN $10 :: boolean IS NOT NULL THEN + t.agents_allowed = $10 :: boolean + ELSE true + END -- Filter by author_id AND CASE - WHEN $10 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN - t.created_by = $10 + WHEN $11 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + t.created_by = $11 ELSE true END -- Filter by author_username AND CASE - WHEN $11 :: text != '' THEN - t.created_by = (SELECT id FROM users WHERE lower(users.username) = lower($11) AND deleted = false) + WHEN $12 :: text != '' THEN + t.created_by = (SELECT id FROM users WHERE lower(users.username) = lower($12) AND deleted = false) ELSE true END -- Filter by has_external_agent in latest version AND CASE - WHEN $12 :: boolean IS NOT NULL THEN - tv.has_external_agent = $12 :: boolean + WHEN $13 :: boolean IS NOT NULL THEN + tv.has_external_agent = $13 :: boolean ELSE true END -- Authorize Filter clause will be injected below in GetAuthorizedTemplates @@ -27008,6 +27017,7 @@ type GetTemplatesWithFilterParams struct { IDs []uuid.UUID `db:"ids" json:"ids"` Deprecated sql.NullBool `db:"deprecated" json:"deprecated"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + AgentsAllowed sql.NullBool `db:"agents_allowed" json:"agents_allowed"` AuthorID uuid.UUID `db:"author_id" json:"author_id"` AuthorUsername string `db:"author_username" json:"author_username"` HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` @@ -27024,6 +27034,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate pq.Array(arg.IDs), arg.Deprecated, arg.HasAITask, + arg.AgentsAllowed, arg.AuthorID, arg.AuthorUsername, arg.HasExternalAgent, @@ -27068,6 +27079,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.CorsBehavior, &i.DisableModuleCache, &i.TimeTilAutostopNotify, + &i.AgentsAllowed, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -38570,7 +38582,7 @@ LEFT JOIN LATERAL ( ) latest_build ON TRUE LEFT JOIN LATERAL ( SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, time_til_autostop_notify + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, time_til_autostop_notify, agents_allowed FROM templates WHERE diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index dc9b72223be..bdc48d74ee9 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -77,6 +77,12 @@ WHERE tv.has_ai_task = sqlc.narg('has_ai_task') :: boolean ELSE true END + -- Filter by agents_allowed + AND CASE + WHEN sqlc.narg('agents_allowed') :: boolean IS NOT NULL THEN + t.agents_allowed = sqlc.narg('agents_allowed') :: boolean + ELSE true + END -- Filter by author_id AND CASE WHEN @author_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index b5cb32ba168..db05b9fd7c0 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -41,7 +41,7 @@ We track the following resources: | PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| | RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| | TaskTable
| |
FieldTracked
created_atfalse
deleted_atfalse
display_nametrue
idtrue
nametrue
organization_idfalse
owner_idtrue
prompttrue
template_parameterstrue
template_version_idtrue
workspace_idtrue
| -| Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
disable_module_cachetrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_autostop_notifytrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| +| Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
agents_allowedtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
disable_module_cachetrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_autostop_notifytrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| | TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| | User
create, write, delete | |
FieldTracked
avatar_urlfalse
chat_spend_limit_microstrue
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_service_accounttrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| | UserSecret
create, write, delete | |
FieldTracked
created_atfalse
descriptiontrue
enabledtrue
env_nametrue
file_pathtrue
idtrue
nametrue
updated_atfalse
user_idtrue
valuetrue
value_key_idfalse
| diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 24cbdcf1dc5..a58d523d7db 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -131,6 +131,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "cors_behavior": ActionTrack, "disable_module_cache": ActionTrack, "time_til_autostop_notify": ActionTrack, + "agents_allowed": ActionTrack, }, &database.TemplateVersion{}: { "id": ActionTrack, From 454c69edecf9194dc53f134acb290ea44941559e Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 27 Jul 2026 10:41:57 +0000 Subject: [PATCH 2/6] nit --- ...n.sql => 000562_template_agents_allowed.down.sql} | 0 ....up.sql => 000562_template_agents_allowed.up.sql} | 1 + coderd/database/migrations/migrate_test.go | 12 ++++++------ 3 files changed, 7 insertions(+), 6 deletions(-) rename coderd/database/migrations/{000552_template_agents_allowed.down.sql => 000562_template_agents_allowed.down.sql} (100%) rename coderd/database/migrations/{000552_template_agents_allowed.up.sql => 000562_template_agents_allowed.up.sql} (95%) diff --git a/coderd/database/migrations/000552_template_agents_allowed.down.sql b/coderd/database/migrations/000562_template_agents_allowed.down.sql similarity index 100% rename from coderd/database/migrations/000552_template_agents_allowed.down.sql rename to coderd/database/migrations/000562_template_agents_allowed.down.sql diff --git a/coderd/database/migrations/000552_template_agents_allowed.up.sql b/coderd/database/migrations/000562_template_agents_allowed.up.sql similarity index 95% rename from coderd/database/migrations/000552_template_agents_allowed.up.sql rename to coderd/database/migrations/000562_template_agents_allowed.up.sql index 6dfa0724a47..4db6fedf9b9 100644 --- a/coderd/database/migrations/000552_template_agents_allowed.up.sql +++ b/coderd/database/migrations/000562_template_agents_allowed.up.sql @@ -43,6 +43,7 @@ BEGIN SET agents_allowed = (id = ANY(parsed_ids)); END $$; +-- As usual, recreate the view so templates.* is expanded to include the new column. DROP VIEW template_with_names; CREATE VIEW template_with_names AS diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index a339eaadef9..7c79ffd6d94 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -1866,13 +1866,13 @@ func TestMigration000558AuditOAuth2ProviderSettingsEnumInSingleTxn(t *testing.T) } //nolint:tparallel,paralleltest // Subtests share one database and exercise sequential migration state. -func TestMigration000552TemplateAgentsAllowedBackfill(t *testing.T) { +func TestMigration000562TemplateAgentsAllowedBackfill(t *testing.T) { t.Parallel() - sqlDB, ctx, orgID, userID, templateIDs := setupMigration000552Templates(t) - upSQL, err := os.ReadFile("000552_template_agents_allowed.up.sql") + sqlDB, ctx, orgID, userID, templateIDs := setupMigration000562Templates(t) + upSQL, err := os.ReadFile("000562_template_agents_allowed.up.sql") require.NoError(t, err) - downSQL, err := os.ReadFile("000552_template_agents_allowed.down.sql") + downSQL, err := os.ReadFile("000562_template_agents_allowed.down.sql") require.NoError(t, err) staleID := uuid.New() @@ -2042,7 +2042,7 @@ func TestMigration000552TemplateAgentsAllowedBackfill(t *testing.T) { } } -func setupMigration000552Templates(t *testing.T) ( +func setupMigration000562Templates(t *testing.T) ( sqlDB *sql.DB, ctx context.Context, orgID uuid.UUID, @@ -2051,7 +2051,7 @@ func setupMigration000552Templates(t *testing.T) ( ) { t.Helper() - const migrationVersion = 552 + const migrationVersion = 562 sqlDB = testSQLDB(t) next, err := migrations.Stepper(sqlDB) From d796bcd74ce0a37128f32c8e2cf7d5ce5002c711 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 4 Aug 2026 14:15:19 +0000 Subject: [PATCH 3/6] review --- .../000562_template_agents_allowed.up.sql | 8 +++---- coderd/database/migrations/migrate_test.go | 24 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/coderd/database/migrations/000562_template_agents_allowed.up.sql b/coderd/database/migrations/000562_template_agents_allowed.up.sql index 4db6fedf9b9..1d93d2198ab 100644 --- a/coderd/database/migrations/000562_template_agents_allowed.up.sql +++ b/coderd/database/migrations/000562_template_agents_allowed.up.sql @@ -33,12 +33,12 @@ BEGIN RAISE EXCEPTION 'contains a null template ID'; END IF; EXCEPTION WHEN others THEN - RAISE WARNING 'agents_template_allowlist is corrupt (%); leaving all templates allowed', SQLERRM; - RETURN; + RAISE WARNING 'agents_template_allowlist is corrupt (%); blocking all templates', SQLERRM; + parsed_ids := ARRAY[]::uuid[]; END; - -- A valid nonempty list allows matching existing templates only. Missing, - -- empty, or corrupt data leaves templates allowed. + -- A valid nonempty list allows matching existing templates only. Missing or + -- empty data leaves templates allowed. Corrupt data blocks all templates. UPDATE templates SET agents_allowed = (id = ANY(parsed_ids)); END $$; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 7c79ffd6d94..4d6f4e5794b 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -1923,8 +1923,8 @@ func TestMigration000562TemplateAgentsAllowedBackfill(t *testing.T) { value: "{", present: true, want: map[uuid.UUID]bool{ - templateIDs[0]: true, - templateIDs[1]: true, + templateIDs[0]: false, + templateIDs[1]: false, }, }, { @@ -1932,8 +1932,8 @@ func TestMigration000562TemplateAgentsAllowedBackfill(t *testing.T) { value: `{}`, present: true, want: map[uuid.UUID]bool{ - templateIDs[0]: true, - templateIDs[1]: true, + templateIDs[0]: false, + templateIDs[1]: false, }, }, { @@ -1941,8 +1941,8 @@ func TestMigration000562TemplateAgentsAllowedBackfill(t *testing.T) { value: `"value"`, present: true, want: map[uuid.UUID]bool{ - templateIDs[0]: true, - templateIDs[1]: true, + templateIDs[0]: false, + templateIDs[1]: false, }, }, { @@ -1950,8 +1950,8 @@ func TestMigration000562TemplateAgentsAllowedBackfill(t *testing.T) { value: `["not-a-uuid"]`, present: true, want: map[uuid.UUID]bool{ - templateIDs[0]: true, - templateIDs[1]: true, + templateIDs[0]: false, + templateIDs[1]: false, }, }, { @@ -1959,8 +1959,8 @@ func TestMigration000562TemplateAgentsAllowedBackfill(t *testing.T) { value: `[null]`, present: true, want: map[uuid.UUID]bool{ - templateIDs[0]: true, - templateIDs[1]: true, + templateIDs[0]: false, + templateIDs[1]: false, }, }, { @@ -1968,8 +1968,8 @@ func TestMigration000562TemplateAgentsAllowedBackfill(t *testing.T) { value: fmt.Sprintf(`[%q,"not-a-uuid"]`, templateIDs[0]), present: true, want: map[uuid.UUID]bool{ - templateIDs[0]: true, - templateIDs[1]: true, + templateIDs[0]: false, + templateIDs[1]: false, }, }, { From 8e34c963e28d209fe4afc9a3f33781d4e13de1e7 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 4 Aug 2026 15:44:16 +0000 Subject: [PATCH 4/6] review --- coderd/database/dbgen/dbgen.go | 1 + coderd/database/dump.sql | 2 +- .../migrations/000562_template_agents_allowed.up.sql | 2 +- coderd/database/models.go | 2 +- coderd/database/querier_test.go | 6 +++--- coderd/database/queries.sql.go | 12 +++++++++--- coderd/database/queries/templates.sql | 8 +++++--- coderd/templatebuilder_handler.go | 1 + coderd/templates.go | 3 +++ 9 files changed, 25 insertions(+), 12 deletions(-) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 577705ec6b5..6f48e42182f 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -555,6 +555,7 @@ func Template(t testing.TB, db database.Store, seed database.Template) database. MaxPortSharingLevel: takeFirst(seed.MaxPortSharingLevel, database.AppSharingLevelOwner), UseClassicParameterFlow: takeFirst(seed.UseClassicParameterFlow, false), CorsBehavior: takeFirst(seed.CorsBehavior, database.CorsBehaviorSimple), + AgentsAllowed: seed.AgentsAllowed, }) require.NoError(t, err, "insert template") diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index c1e9c6e438f..808111c3764 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -3487,7 +3487,7 @@ COMMENT ON COLUMN templates.use_classic_parameter_flow IS 'Determines whether to COMMENT ON COLUMN templates.time_til_autostop_notify IS 'How long before the workspace autostop deadline to send a reminder notification, in nanoseconds. 0 disables the notification.'; -COMMENT ON COLUMN templates.agents_allowed IS 'Whether Coder Agents can use this template to create workspaces.'; +COMMENT ON COLUMN templates.agents_allowed IS 'Whether Coder Agents can create workspaces using this template.'; CREATE VIEW template_with_names AS SELECT templates.id, diff --git a/coderd/database/migrations/000562_template_agents_allowed.up.sql b/coderd/database/migrations/000562_template_agents_allowed.up.sql index 1d93d2198ab..e8838857936 100644 --- a/coderd/database/migrations/000562_template_agents_allowed.up.sql +++ b/coderd/database/migrations/000562_template_agents_allowed.up.sql @@ -1,6 +1,6 @@ ALTER TABLE templates ADD COLUMN agents_allowed boolean DEFAULT true NOT NULL; -COMMENT ON COLUMN templates.agents_allowed IS 'Whether Coder Agents can use this template to create workspaces.'; +COMMENT ON COLUMN templates.agents_allowed IS 'Whether Coder Agents can create workspaces using this template.'; DO $$ DECLARE diff --git a/coderd/database/models.go b/coderd/database/models.go index 954a701a27b..2a265fc9b1b 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5963,7 +5963,7 @@ type TemplateTable struct { DisableModuleCache bool `db:"disable_module_cache" json:"disable_module_cache"` // How long before the workspace autostop deadline to send a reminder notification, in nanoseconds. 0 disables the notification. TimeTilAutostopNotify int64 `db:"time_til_autostop_notify" json:"time_til_autostop_notify"` - // Whether Coder Agents can use this template to create workspaces. + // Whether Coder Agents can create workspaces using this template. AgentsAllowed bool `db:"agents_allowed" json:"agents_allowed"` } diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index ac866e3921c..e84b81b79cc 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -894,21 +894,21 @@ func TestGetWorkspaceAgentUsageStats(t *testing.T) { func TestGetTemplatesWithAgentsAllowedFilter(t *testing.T) { t.Parallel() - db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitMedium) org := dbgen.Organization(t, db, database.Organization{}) user := dbgen.User(t, db, database.User{}) allowed := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, + AgentsAllowed: true, }) require.True(t, allowed.AgentsAllowed) blocked := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, + AgentsAllowed: false, }) - _, err := sqlDB.ExecContext(ctx, `UPDATE templates SET agents_allowed = false WHERE id = $1`, blocked.ID) - require.NoError(t, err) tests := []struct { name string diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a9d5f87ae6f..8e9f76689b4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -27119,10 +27119,11 @@ INSERT INTO allow_user_cancel_workspace_jobs, max_port_sharing_level, use_classic_parameter_flow, - cors_behavior + cors_behavior, + agents_allowed ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) ` type InsertTemplateParams struct { @@ -27143,6 +27144,7 @@ type InsertTemplateParams struct { MaxPortSharingLevel AppSharingLevel `db:"max_port_sharing_level" json:"max_port_sharing_level"` UseClassicParameterFlow bool `db:"use_classic_parameter_flow" json:"use_classic_parameter_flow"` CorsBehavior CorsBehavior `db:"cors_behavior" json:"cors_behavior"` + AgentsAllowed bool `db:"agents_allowed" json:"agents_allowed"` } func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParams) error { @@ -27164,6 +27166,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam arg.MaxPortSharingLevel, arg.UseClassicParameterFlow, arg.CorsBehavior, + arg.AgentsAllowed, ) return err } @@ -27266,7 +27269,8 @@ SET max_port_sharing_level = $9, use_classic_parameter_flow = $10, cors_behavior = $11, - disable_module_cache = $12 + disable_module_cache = $12, + agents_allowed = $13 WHERE id = $1 ` @@ -27284,6 +27288,7 @@ type UpdateTemplateMetaByIDParams struct { UseClassicParameterFlow bool `db:"use_classic_parameter_flow" json:"use_classic_parameter_flow"` CorsBehavior CorsBehavior `db:"cors_behavior" json:"cors_behavior"` DisableModuleCache bool `db:"disable_module_cache" json:"disable_module_cache"` + AgentsAllowed bool `db:"agents_allowed" json:"agents_allowed"` } func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) error { @@ -27300,6 +27305,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl arg.UseClassicParameterFlow, arg.CorsBehavior, arg.DisableModuleCache, + arg.AgentsAllowed, ) return err } diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index bdc48d74ee9..aea8169094d 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -143,10 +143,11 @@ INSERT INTO allow_user_cancel_workspace_jobs, max_port_sharing_level, use_classic_parameter_flow, - cors_behavior + cors_behavior, + agents_allowed ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17); + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18); -- name: UpdateTemplateActiveVersionByID :exec UPDATE @@ -180,7 +181,8 @@ SET max_port_sharing_level = $9, use_classic_parameter_flow = $10, cors_behavior = $11, - disable_module_cache = $12 + disable_module_cache = $12, + agents_allowed = $13 WHERE id = $1 ; diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go index adc4da2d77c..dc939a9c8bb 100644 --- a/coderd/templatebuilder_handler.go +++ b/coderd/templatebuilder_handler.go @@ -555,6 +555,7 @@ func (api *API) templateBuilderCreateTemplate(rw http.ResponseWriter, r *http.Re MaxPortSharingLevel: database.AppSharingLevelOwner, UseClassicParameterFlow: false, CorsBehavior: database.CorsBehaviorSimple, + AgentsAllowed: true, }) if err != nil { if database.IsUniqueViolation(err, database.UniqueTemplatesOrganizationIDNameIndex) { diff --git a/coderd/templates.go b/coderd/templates.go index 2e3b539d81d..28edf1d8eb5 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -224,6 +224,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque Icon: createTemplate.Icon, DisplayName: createTemplate.DisplayName, UseClassicParameterFlow: useClassicParameterFlow, + AgentsAllowed: true, } _, err := api.Database.GetTemplateByOrganizationAndName(ctx, database.GetTemplateByOrganizationAndNameParams{ @@ -447,6 +448,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque MaxPortSharingLevel: maxPortShareLevel, UseClassicParameterFlow: useClassicParameterFlow, CorsBehavior: corsBehavior, + AgentsAllowed: true, }) if err != nil { return xerrors.Errorf("insert template: %s", err) @@ -778,6 +780,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { UseClassicParameterFlow: resolved.useClassicTemplateFlow, CorsBehavior: resolved.corsBehavior, DisableModuleCache: resolved.disableModuleCache, + AgentsAllowed: template.AgentsAllowed, }) if err != nil { return xerrors.Errorf("update template metadata: %w", err) From 376f9a0853c15b260435ccade39b2c554675bde0 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 5 Aug 2026 05:43:10 +0000 Subject: [PATCH 5/6] review --- ....down.sql => 000563_template_agents_allowed.down.sql} | 0 ...owed.up.sql => 000563_template_agents_allowed.up.sql} | 7 +++++-- coderd/database/migrations/migrate_test.go | 9 +++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) rename coderd/database/migrations/{000562_template_agents_allowed.down.sql => 000563_template_agents_allowed.down.sql} (100%) rename coderd/database/migrations/{000562_template_agents_allowed.up.sql => 000563_template_agents_allowed.up.sql} (93%) diff --git a/coderd/database/migrations/000562_template_agents_allowed.down.sql b/coderd/database/migrations/000563_template_agents_allowed.down.sql similarity index 100% rename from coderd/database/migrations/000562_template_agents_allowed.down.sql rename to coderd/database/migrations/000563_template_agents_allowed.down.sql diff --git a/coderd/database/migrations/000562_template_agents_allowed.up.sql b/coderd/database/migrations/000563_template_agents_allowed.up.sql similarity index 93% rename from coderd/database/migrations/000562_template_agents_allowed.up.sql rename to coderd/database/migrations/000563_template_agents_allowed.up.sql index e8838857936..672c8eccd1a 100644 --- a/coderd/database/migrations/000562_template_agents_allowed.up.sql +++ b/coderd/database/migrations/000563_template_agents_allowed.up.sql @@ -18,6 +18,9 @@ BEGIN BEGIN parsed := raw::jsonb; + IF parsed = 'null'::jsonb THEN + RETURN; + END IF; IF jsonb_typeof(parsed) <> 'array' THEN RAISE EXCEPTION 'value is not a JSON array'; END IF; @@ -37,8 +40,8 @@ BEGIN parsed_ids := ARRAY[]::uuid[]; END; - -- A valid nonempty list allows matching existing templates only. Missing or - -- empty data leaves templates allowed. Corrupt data blocks all templates. + -- A valid nonempty list allows matching existing templates only. Missing, null, + -- or empty data leaves templates allowed. Corrupt data blocks all templates. UPDATE templates SET agents_allowed = (id = ANY(parsed_ids)); END $$; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 4d6f4e5794b..c4fe5a3102d 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -1918,6 +1918,15 @@ func TestMigration000562TemplateAgentsAllowedBackfill(t *testing.T) { templateIDs[1]: true, }, }, + { + name: "JSON null", + value: "null", + present: true, + want: map[uuid.UUID]bool{ + templateIDs[0]: true, + templateIDs[1]: true, + }, + }, { name: "invalid JSON", value: "{", From 8f60ba29ca99c2b50211931928a6084c7a87d2be Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 6 Aug 2026 03:41:50 +0000 Subject: [PATCH 6/6] migration numbers --- coderd/database/migrations/migrate_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index c4fe5a3102d..cc1beeea3a2 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -1866,13 +1866,13 @@ func TestMigration000558AuditOAuth2ProviderSettingsEnumInSingleTxn(t *testing.T) } //nolint:tparallel,paralleltest // Subtests share one database and exercise sequential migration state. -func TestMigration000562TemplateAgentsAllowedBackfill(t *testing.T) { +func TestMigration000563TemplateAgentsAllowedBackfill(t *testing.T) { t.Parallel() - sqlDB, ctx, orgID, userID, templateIDs := setupMigration000562Templates(t) - upSQL, err := os.ReadFile("000562_template_agents_allowed.up.sql") + sqlDB, ctx, orgID, userID, templateIDs := setupMigration000563Templates(t) + upSQL, err := os.ReadFile("000563_template_agents_allowed.up.sql") require.NoError(t, err) - downSQL, err := os.ReadFile("000562_template_agents_allowed.down.sql") + downSQL, err := os.ReadFile("000563_template_agents_allowed.down.sql") require.NoError(t, err) staleID := uuid.New() @@ -2051,7 +2051,7 @@ func TestMigration000562TemplateAgentsAllowedBackfill(t *testing.T) { } } -func setupMigration000562Templates(t *testing.T) ( +func setupMigration000563Templates(t *testing.T) ( sqlDB *sql.DB, ctx context.Context, orgID uuid.UUID,