Files
coder/coderd/templates_meta_update.go
T
Ethan 0ac23e3ee1 feat: add per-template Coder Agents access control (#27285)
Relates to CODAGT-713

Depends on #27284

This makes the per-template `agents_allowed` field authoritative in the API and chatd. It adds optional create and metadata update fields with the intended default and omission semantics, supports `agents-allowed:` template search, includes the value in telemetry, and makes `list_templates`, `read_template`, and `create_workspace` read the template row directly. Existing-workspace retries remain idempotent, and blocked same-organisation templates return an actionable message.

The experimental `/template-allowlist` routes remain temporarily because the shipped AI Settings page still calls them, but they no longer control chatd enforcement. #27514 moves that page to per-template metadata, #27515 removes the legacy storage, routes, SDK types, and utility, #27517 adds the CLI flags, and #27518 updates the platform controls documentation for the per-template model, directly addressing CRF-5 and CRF-6. The stack is intended to merge as a unit.
2026-08-06 14:14:37 +10:00

174 lines
7.7 KiB
Go

package coderd
import (
"strings"
"time"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
)
// templateMetaUpdate is the resolved set of values to apply for a
// PATCH /templates/{template} request. Any field on
// codersdk.UpdateTemplateMeta that is nil falls back to the existing
// template's value so that omitted request fields are not modified.
type templateMetaUpdate struct {
name string
displayName string
description string
icon string
defaultTTLMillis int64
activityBumpMillis int64
timeTilAutostopNotifyMillis int64
failureTTLMillis int64
timeTilDormantMillis int64
timeTilDormantAutoDeleteMillis int64
allowUserAutostart bool
allowUserAutostop bool
allowUserCancelWorkspaceJobs bool
agentsAllowed bool
requireActiveVersion bool
deprecationMessage string
useClassicTemplateFlow bool
disableModuleCache bool
corsBehavior database.CorsBehavior
autostopRequirementDaysOfWeekParsed uint8
autostartRequirementDaysOfWeekParsed uint8
autostopRequirementWeeks int64
groupACL database.TemplateACL
// updateWorkspaceLastUsedAtIntent and updateWorkspaceDormantAtIntent are one-shot
// intents that trigger side effects only when the request explicitly
// sets the field to true. nil and false are no-ops.
updateWorkspaceLastUsedAtIntent bool
updateWorkspaceDormantAtIntent bool
}
// resolveTemplateMetaUpdate produces a templateMetaUpdate populated with
// either the request value (when present) or the existing template's
// value (when the request field is nil).
//
// This function validates shape, not contents: it parses the
// autostop/autostart day-of-week strings into bitmaps and ensures any
// non-empty CORS behavior is a recognized enum. Errors it returns are
// user-facing validation errors the caller must surface as 400 Bad
// Request.
//
// Range and content checks (e.g. activityBumpMillis >= 0,
// failureTTLMillis >= 1 minute, max port share level) and validation
// that depends on external interfaces (such as port-sharing licensure)
// are the caller's responsibility.
func resolveTemplateMetaUpdate(
template database.Template,
scheduleOpts schedule.TemplateScheduleOptions,
req codersdk.UpdateTemplateMeta,
) (templateMetaUpdate, []codersdk.ValidationError) {
var validErrs []codersdk.ValidationError
out := templateMetaUpdate{
name: ptr.NilToDefault(req.Name, template.Name),
displayName: ptr.NilToDefault(req.DisplayName, template.DisplayName),
description: ptr.NilToDefault(req.Description, template.Description),
icon: ptr.NilToDefault(req.Icon, template.Icon),
defaultTTLMillis: ptr.NilToDefault(req.DefaultTTLMillis, time.Duration(template.DefaultTTL).Milliseconds()),
activityBumpMillis: ptr.NilToDefault(req.ActivityBumpMillis, time.Duration(template.ActivityBump).Milliseconds()),
timeTilAutostopNotifyMillis: ptr.NilToDefault(req.TimeTilAutostopNotifyMillis, time.Duration(template.TimeTilAutostopNotify).Milliseconds()),
failureTTLMillis: ptr.NilToDefault(req.FailureTTLMillis, time.Duration(template.FailureTTL).Milliseconds()),
timeTilDormantMillis: ptr.NilToDefault(req.TimeTilDormantMillis, time.Duration(template.TimeTilDormant).Milliseconds()),
timeTilDormantAutoDeleteMillis: ptr.NilToDefault(req.TimeTilDormantAutoDeleteMillis, time.Duration(template.TimeTilDormantAutoDelete).Milliseconds()),
allowUserAutostart: ptr.NilToDefault(req.AllowUserAutostart, template.AllowUserAutostart),
allowUserAutostop: ptr.NilToDefault(req.AllowUserAutostop, template.AllowUserAutostop),
allowUserCancelWorkspaceJobs: ptr.NilToDefault(req.AllowUserCancelWorkspaceJobs, template.AllowUserCancelWorkspaceJobs),
agentsAllowed: ptr.NilToDefault(req.AgentsAllowed, template.AgentsAllowed),
requireActiveVersion: ptr.NilToDefault(req.RequireActiveVersion, template.RequireActiveVersion),
deprecationMessage: ptr.NilToDefault(req.DeprecationMessage, template.Deprecated),
useClassicTemplateFlow: ptr.NilToDefault(req.UseClassicParameterFlow, template.UseClassicParameterFlow),
disableModuleCache: ptr.NilToDefault(req.DisableModuleCache, template.DisableModuleCache),
groupACL: template.GroupACL,
// Default to the original values
corsBehavior: template.CorsBehavior,
autostopRequirementDaysOfWeekParsed: scheduleOpts.AutostopRequirement.DaysOfWeek,
autostopRequirementWeeks: scheduleOpts.AutostopRequirement.Weeks,
autostartRequirementDaysOfWeekParsed: scheduleOpts.AutostartRequirement.DaysOfWeek,
updateWorkspaceLastUsedAtIntent: false,
updateWorkspaceDormantAtIntent: false,
}
// Users should not be able to clear the template name. This is the only field
// that treats a zero value as omitted.
if out.name == "" {
out.name = template.Name
}
// Override autostop if provided is non-nil
if req.AutostopRequirement != nil {
bitmap, err := codersdk.WeekdaysToBitmap(req.AutostopRequirement.DaysOfWeek)
if err != nil {
validErrs = append(validErrs, codersdk.ValidationError{
Field: "autostop_requirement.days_of_week",
Detail: err.Error(),
})
} else {
out.autostopRequirementDaysOfWeekParsed = bitmap
out.autostopRequirementWeeks = req.AutostopRequirement.Weeks
}
// Always force <= 0 -> 1
if out.autostopRequirementWeeks <= 0 {
out.autostopRequirementWeeks = defaultRequirementWeeks
}
}
// Override autostart if provided is non-nil
if req.AutostartRequirement != nil {
bitmap, err := codersdk.WeekdaysToBitmap(req.AutostartRequirement.DaysOfWeek)
if err != nil {
validErrs = append(validErrs, codersdk.ValidationError{
Field: "autostart_requirement.days_of_week",
Detail: err.Error(),
})
} else {
out.autostartRequirementDaysOfWeekParsed = bitmap
}
}
// Resolve CORS behavior. An empty string is treated as "do not
// change" because the existing UI-driven flow used to send empty
// strings for unset values. A non-empty invalid value is a
// validation error.
if req.CORSBehavior != nil && *req.CORSBehavior != "" {
val := database.CorsBehavior(*req.CORSBehavior)
if !val.Valid() {
validErrs = append(validErrs, codersdk.ValidationError{
Field: "cors_behavior",
Detail: "Invalid CORS behavior \"" + string(*req.CORSBehavior) +
"\". Must be one of [" + strings.Join(slice.ToStrings(database.AllCorsBehaviorValues()), ", ") + "]",
})
} else {
out.corsBehavior = val
}
}
if req.DisableEveryoneGroupAccess != nil && *req.DisableEveryoneGroupAccess {
// Remove the "everyone" group from the template. If this is set to false, the
// user needs to explicitly add the "everyone" group back to the ACL via the
// group ACL endpoints, so we don't treat false as a no-op.
delete(out.groupACL, template.OrganizationID.String())
}
// One-shot intent flags. nil and false are both no-ops; true is a
// trigger to run the side effect.
if req.UpdateWorkspaceLastUsedAt != nil && *req.UpdateWorkspaceLastUsedAt {
out.updateWorkspaceLastUsedAtIntent = true
}
if req.UpdateWorkspaceDormantAt != nil && *req.UpdateWorkspaceDormantAt {
out.updateWorkspaceDormantAtIntent = true
}
return out, validErrs
}