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
| |
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 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,