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 8af6db94bab..808111c3764 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 create workspaces using this template.';
+
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/000563_template_agents_allowed.down.sql b/coderd/database/migrations/000563_template_agents_allowed.down.sql
new file mode 100644
index 00000000000..a7131843088
--- /dev/null
+++ b/coderd/database/migrations/000563_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/000563_template_agents_allowed.up.sql b/coderd/database/migrations/000563_template_agents_allowed.up.sql
new file mode 100644
index 00000000000..672c8eccd1a
--- /dev/null
+++ b/coderd/database/migrations/000563_template_agents_allowed.up.sql
@@ -0,0 +1,64 @@
+ALTER TABLE templates ADD COLUMN agents_allowed boolean DEFAULT true NOT NULL;
+
+COMMENT ON COLUMN templates.agents_allowed IS 'Whether Coder Agents can create workspaces using this template.';
+
+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 parsed = 'null'::jsonb THEN
+ RETURN;
+ END IF;
+ 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 (%); blocking all templates', SQLERRM;
+ parsed_ids := ARRAY[]::uuid[];
+ END;
+
+ -- 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 $$;
+
+-- 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
+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..cc1beeea3a2 100644
--- a/coderd/database/migrations/migrate_test.go
+++ b/coderd/database/migrations/migrate_test.go
@@ -1865,6 +1865,244 @@ func TestMigration000558AuditOAuth2ProviderSettingsEnumInSingleTxn(t *testing.T)
require.NoError(t, err)
}
+//nolint:tparallel,paralleltest // Subtests share one database and exercise sequential migration state.
+func TestMigration000563TemplateAgentsAllowedBackfill(t *testing.T) {
+ t.Parallel()
+
+ 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("000563_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: "JSON null",
+ value: "null",
+ 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]: false,
+ templateIDs[1]: false,
+ },
+ },
+ {
+ name: "JSON object",
+ value: `{}`,
+ present: true,
+ want: map[uuid.UUID]bool{
+ templateIDs[0]: false,
+ templateIDs[1]: false,
+ },
+ },
+ {
+ name: "JSON scalar",
+ value: `"value"`,
+ present: true,
+ want: map[uuid.UUID]bool{
+ templateIDs[0]: false,
+ templateIDs[1]: false,
+ },
+ },
+ {
+ name: "invalid UUID element",
+ value: `["not-a-uuid"]`,
+ present: true,
+ want: map[uuid.UUID]bool{
+ templateIDs[0]: false,
+ templateIDs[1]: false,
+ },
+ },
+ {
+ name: "null element",
+ value: `[null]`,
+ present: true,
+ want: map[uuid.UUID]bool{
+ templateIDs[0]: false,
+ templateIDs[1]: false,
+ },
+ },
+ {
+ name: "mixed valid and invalid elements",
+ value: fmt.Sprintf(`[%q,"not-a-uuid"]`, templateIDs[0]),
+ present: true,
+ want: map[uuid.UUID]bool{
+ templateIDs[0]: false,
+ templateIDs[1]: false,
+ },
+ },
+ {
+ 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 setupMigration000563Templates(t *testing.T) (
+ sqlDB *sql.DB,
+ ctx context.Context,
+ orgID uuid.UUID,
+ userID uuid.UUID,
+ templateIDs []uuid.UUID,
+) {
+ t.Helper()
+
+ const migrationVersion = 562
+
+ 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..2a265fc9b1b 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 create workspaces using this template.
+ 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..e84b81b79cc 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, _ := 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,
+ })
+
+ 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..8e9f76689b4 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,
@@ -27107,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 {
@@ -27131,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 {
@@ -27152,6 +27166,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam
arg.MaxPortSharingLevel,
arg.UseClassicParameterFlow,
arg.CorsBehavior,
+ arg.AgentsAllowed,
)
return err
}
@@ -27254,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
`
@@ -27272,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 {
@@ -27288,6 +27305,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl
arg.UseClassicParameterFlow,
arg.CorsBehavior,
arg.DisableModuleCache,
+ arg.AgentsAllowed,
)
return err
}
@@ -38570,7 +38588,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..aea8169094d 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
@@ -137,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
@@ -174,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)
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
|
| Field | Tracked |
| | id | false |
| reconciliation_paused | true |
|
| RoleSyncSettings
| | Field | Tracked |
| | field | true |
| mapping | true |
|
| TaskTable
| | Field | Tracked |
| | created_at | false |
| deleted_at | false |
| display_name | true |
| id | true |
| name | true |
| organization_id | false |
| owner_id | true |
| prompt | true |
| template_parameters | true |
| template_version_id | true |
| workspace_id | true |
|
-| Template
write, delete | | Field | Tracked |
| | active_version_id | true |
| activity_bump | true |
| allow_user_autostart | true |
| allow_user_autostop | true |
| allow_user_cancel_workspace_jobs | true |
| autostart_block_days_of_week | true |
| autostop_requirement_days_of_week | true |
| autostop_requirement_weeks | true |
| cors_behavior | true |
| created_at | false |
| created_by | true |
| created_by_avatar_url | false |
| created_by_name | false |
| created_by_username | false |
| default_ttl | true |
| deleted | false |
| deprecated | true |
| description | true |
| disable_module_cache | true |
| display_name | true |
| failure_ttl | true |
| group_acl | true |
| icon | true |
| id | true |
| max_port_sharing_level | true |
| name | true |
| organization_display_name | false |
| organization_icon | false |
| organization_id | false |
| organization_name | false |
| provisioner | true |
| require_active_version | true |
| time_til_autostop_notify | true |
| time_til_dormant | true |
| time_til_dormant_autodelete | true |
| updated_at | false |
| use_classic_parameter_flow | true |
| user_acl | true |
|
+| Template
write, delete | | Field | Tracked |
| | active_version_id | true |
| activity_bump | true |
| agents_allowed | true |
| allow_user_autostart | true |
| allow_user_autostop | true |
| allow_user_cancel_workspace_jobs | true |
| autostart_block_days_of_week | true |
| autostop_requirement_days_of_week | true |
| autostop_requirement_weeks | true |
| cors_behavior | true |
| created_at | false |
| created_by | true |
| created_by_avatar_url | false |
| created_by_name | false |
| created_by_username | false |
| default_ttl | true |
| deleted | false |
| deprecated | true |
| description | true |
| disable_module_cache | true |
| display_name | true |
| failure_ttl | true |
| group_acl | true |
| icon | true |
| id | true |
| max_port_sharing_level | true |
| name | true |
| organization_display_name | false |
| organization_icon | false |
| organization_id | false |
| organization_name | false |
| provisioner | true |
| require_active_version | true |
| time_til_autostop_notify | true |
| time_til_dormant | true |
| time_til_dormant_autodelete | true |
| updated_at | false |
| use_classic_parameter_flow | true |
| user_acl | true |
|
| TemplateVersion
create, write | | Field | Tracked |
| | archived | true |
| created_at | false |
| created_by | true |
| created_by_avatar_url | false |
| created_by_name | false |
| created_by_username | false |
| external_auth_providers | false |
| has_ai_task | false |
| has_external_agent | false |
| id | true |
| job_id | false |
| message | false |
| name | true |
| organization_id | false |
| readme | true |
| source_example_id | false |
| template_id | true |
| updated_at | false |
|
| User
create, write, delete | | Field | Tracked |
| | avatar_url | false |
| chat_spend_limit_micros | true |
| created_at | false |
| deleted | true |
| email | true |
| github_com_user_id | false |
| hashed_one_time_passcode | false |
| hashed_password | true |
| id | true |
| is_service_account | true |
| is_system | true |
| last_seen_at | false |
| login_type | true |
| name | true |
| one_time_passcode_expires_at | true |
| quiet_hours_schedule | true |
| rbac_roles | true |
| status | true |
| updated_at | false |
| username | true |
|
| UserSecret
create, write, delete | | Field | Tracked |
| | created_at | false |
| description | true |
| enabled | true |
| env_name | true |
| file_path | true |
| id | true |
| name | true |
| updated_at | false |
| user_id | true |
| value | true |
| value_key_id | false |
|
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,