From b3485d9b3a44562d5070a2dd97883bc58442e365 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Thu, 6 Aug 2026 14:04:23 +1000
Subject: [PATCH] chore: add agents_allowed to templates (#27284)
Relates to CODAGT-713
This adds `templates.agents_allowed` as a default-true, auditable template attribute, along with nullable database filtering. Migration `000562` translates the effective legacy `agents_template_allowlist` state for existing templates: a valid nonempty list allows matching templates and blocks the rest, missing or empty values leave templates allowed, whilst corrupt values fail closed by blocking all existing templates. As per the linear issue, new templates deliberately default to allowed under the per-template model.
This is the database-only first PR in the stack. #27285 makes the field authoritative in the API and chatd whilst temporarily retaining the compatibility routes needed by the shipped frontend. Later PRs migrate the UI, remove the legacy storage, routes, SDK types, and utility, then add CLI flags.
---
coderd/database/dbgen/dbgen.go | 1 +
coderd/database/dump.sql | 6 +-
.../000563_template_agents_allowed.down.sql | 17 ++
.../000563_template_agents_allowed.up.sql | 64 +++++
coderd/database/migrations/migrate_test.go | 238 ++++++++++++++++++
coderd/database/modelqueries.go | 2 +
coderd/database/models.go | 3 +
coderd/database/querier_test.go | 88 +++++++
coderd/database/queries.sql.go | 46 ++--
coderd/database/queries/templates.sql | 14 +-
coderd/templatebuilder_handler.go | 1 +
coderd/templates.go | 3 +
docs/admin/security/audit-logs.md | 2 +-
enterprise/audit/table.go | 1 +
14 files changed, 467 insertions(+), 19 deletions(-)
create mode 100644 coderd/database/migrations/000563_template_agents_allowed.down.sql
create mode 100644 coderd/database/migrations/000563_template_agents_allowed.up.sql
diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go
index 577705ec6b..6f48e42182 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 8af6db94ba..808111c376 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 0000000000..a713184308
--- /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 0000000000..672c8eccd1
--- /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 b508d5d1e9..cc1beeea3a 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 e7323bd650..79f1d91095 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 6be9b7d19c..2a265fc9b1 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 52ed5fa13a..e84b81b79c 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 2820465254..8e9f76689b 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 dc9b72223b..aea8169094 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 adc4da2d77..dc939a9c8b 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 2e3b539d81..28edf1d8eb 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 b5cb32ba16..db05b9fd7c 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 24cbdcf1dc..a58d523d7d 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,