diff --git a/cli/server.go b/cli/server.go index 6b6b218fb7d6a..a6a08a0b947fd 100644 --- a/cli/server.go +++ b/cli/server.go @@ -940,7 +940,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. autobuildTicker := time.NewTicker(vals.AutobuildPollInterval.Value()) defer autobuildTicker.Stop() autobuildExecutor := autobuild.NewExecutor( - ctx, options.Database, options.Pubsub, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, logger, autobuildTicker.C) + ctx, options.Database, options.Pubsub, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, logger, autobuildTicker.C) autobuildExecutor.Run() hangDetectorTicker := time.NewTicker(vals.JobHangDetectorInterval.Value()) diff --git a/cli/templates.go b/cli/templates.go index 2dec46d226b5c..4f5b4f8f36d0b 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -6,8 +6,6 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/pretty" - "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" diff --git a/cli/templateversionarchive.go b/cli/templateversionarchive.go index 740de869ff9c2..63c9d8a3de212 100644 --- a/cli/templateversionarchive.go +++ b/cli/templateversionarchive.go @@ -8,8 +8,6 @@ import ( "golang.org/x/xerrors" - "github.com/coder/pretty" - "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" diff --git a/cli/templateversions.go b/cli/templateversions.go index 5b99a46310996..a27d6a6d65af3 100644 --- a/cli/templateversions.go +++ b/cli/templateversions.go @@ -8,8 +8,6 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/pretty" - "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b5041179736d2..4151c62b40202 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7816,6 +7816,10 @@ const docTemplate = `{ "description": "Name is the name of the template.", "type": "string" }, + "require_active_version": { + "description": "RequireActiveVersion mandates that workspaces are built with the active\ntemplate version.", + "type": "boolean" + }, "template_version_id": { "description": "VersionID is an in-progress or completed job to use as an initial version\nof the template.\n\nThis is required on creation to enable a user-flow of validating a\ntemplate works. There is no reason the data-model cannot support empty\ntemplates, but it doesn't make sense for users.", "type": "string", @@ -9994,6 +9998,10 @@ const docTemplate = `{ "terraform" ] }, + "require_active_version": { + "description": "RequireActiveVersion mandates that workspaces are built with the active\ntemplate version.", + "type": "boolean" + }, "time_til_dormant_autodelete_ms": { "type": "integer" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 6e2e5c0902ddb..df54a31a85a54 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6969,6 +6969,10 @@ "description": "Name is the name of the template.", "type": "string" }, + "require_active_version": { + "description": "RequireActiveVersion mandates that workspaces are built with the active\ntemplate version.", + "type": "boolean" + }, "template_version_id": { "description": "VersionID is an in-progress or completed job to use as an initial version\nof the template.\n\nThis is required on creation to enable a user-flow of validating a\ntemplate works. There is no reason the data-model cannot support empty\ntemplates, but it doesn't make sense for users.", "type": "string", @@ -9028,6 +9032,10 @@ "type": "string", "enum": ["terraform"] }, + "require_active_version": { + "description": "RequireActiveVersion mandates that workspaces are built with the active\ntemplate version.", + "type": "boolean" + }, "time_til_dormant_autodelete_ms": { "type": "integer" }, diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index f3b3ff58460b0..d2fb16b033946 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -32,6 +32,7 @@ type Executor struct { db database.Store ps pubsub.Pubsub templateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] + accessControlStore *atomic.Pointer[dbauthz.AccessControlStore] auditor *atomic.Pointer[audit.Auditor] log slog.Logger tick <-chan time.Time @@ -46,7 +47,7 @@ type Stats struct { } // New returns a new wsactions executor. -func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, tss *atomic.Pointer[schedule.TemplateScheduleStore], auditor *atomic.Pointer[audit.Auditor], log slog.Logger, tick <-chan time.Time) *Executor { +func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, tss *atomic.Pointer[schedule.TemplateScheduleStore], auditor *atomic.Pointer[audit.Auditor], acs *atomic.Pointer[dbauthz.AccessControlStore], log slog.Logger, tick <-chan time.Time) *Executor { le := &Executor{ //nolint:gocritic // Autostart has a limited set of permissions. ctx: dbauthz.AsAutostart(ctx), @@ -56,6 +57,7 @@ func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, tss * tick: tick, log: log.Named("autobuild"), auditor: auditor, + accessControlStore: acs, } return le } @@ -159,6 +161,12 @@ func (e *Executor) runOnce(t time.Time) Stats { return nil } + template, err := tx.GetTemplateByID(e.ctx, ws.TemplateID) + if err != nil { + log.Warn(e.ctx, "get template by id", slog.Error(err)) + } + accessControl := (*(e.accessControlStore.Load())).GetTemplateAccessControl(template) + latestJob, err := tx.GetProvisionerJobByID(e.ctx, latestBuild.JobID) if err != nil { log.Warn(e.ctx, "get last provisioner job for workspace %q: %w", slog.Error(err)) @@ -179,7 +187,7 @@ func (e *Executor) runOnce(t time.Time) Stats { Reason(reason) log.Debug(e.ctx, "auto building workspace", slog.F("transition", nextTransition)) if nextTransition == database.WorkspaceTransitionStart && - ws.AutomaticUpdates == database.AutomaticUpdatesAlways { + useActiveVersion(accessControl, ws) { log.Debug(e.ctx, "autostarting with active version") builder = builder.ActiveVersion() } @@ -470,3 +478,7 @@ func auditBuild(ctx context.Context, log slog.Logger, auditor audit.Auditor, par AdditionalFields: raw, }) } + +func useActiveVersion(opts dbauthz.TemplateAccessControl, ws database.Workspace) bool { + return opts.RequireActiveVersion || ws.AutomaticUpdates == database.AutomaticUpdatesAlways +} diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index fd37166ea86db..81bbf80603898 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -783,6 +783,56 @@ func TestExecutorAutostopTemplateDisabled(t *testing.T) { assert.Len(t, stats.Transitions, 0) } +// Test that an AGPL AccessControlStore properly disables +// functionality. +func TestExecutorRequireActiveVersion(t *testing.T) { + t.Parallel() + + var ( + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") + ticker = make(chan time.Time) + statCh = make(chan autobuild.Stats) + + ownerClient = coderdtest.New(t, &coderdtest.Options{ + AutobuildTicker: ticker, + IncludeProvisionerDaemon: true, + AutobuildStats: statCh, + TemplateScheduleStore: schedule.NewAGPLTemplateScheduleStore(), + }) + ) + owner := coderdtest.CreateFirstUser(t, ownerClient) + + // Create an active and inactive template version. We'll + // build a regular member's workspace using a non-active + // template version and assert that the field is not abided + // since there is no enterprise license. + activeVersion := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, ownerClient, owner.OrganizationID, activeVersion.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.RequireActiveVersion = true + ctr.VersionID = activeVersion.ID + }) + inactiveVersion := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, activeVersion.ID) + memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + ws := coderdtest.CreateWorkspace(t, memberClient, owner.OrganizationID, uuid.Nil, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TemplateVersionID = inactiveVersion.ID + cwr.AutostartSchedule = ptr.Ref(sched.String()) + }) + _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, ownerClient, ws.LatestBuild.ID) + ws = coderdtest.MustTransitionWorkspace(t, memberClient, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) { + req.TemplateVersionID = inactiveVersion.ID + }) + require.Equal(t, inactiveVersion.ID, ws.LatestBuild.TemplateVersionID) + ticker <- sched.Next(ws.LatestBuild.CreatedAt) + stats := <-statCh + require.Len(t, stats.Transitions, 1) + + ws = coderdtest.MustWorkspace(t, memberClient, ws.ID) + require.Equal(t, inactiveVersion.ID, ws.LatestBuild.TemplateVersionID) +} + // TestExecutorFailedWorkspace test AGPL functionality which mainly // ensures that autostop actions as a result of a failed workspace // build do not trigger. diff --git a/coderd/coderd.go b/coderd/coderd.go index 7ab2e578462b1..81ec1e87e6b18 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -131,6 +131,7 @@ type Options struct { SetUserSiteRoles func(ctx context.Context, logger slog.Logger, tx database.Store, userID uuid.UUID, roles []string) error TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] + AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore] // AppSecurityKey is the crypto key used to sign and encrypt tokens related to // workspace applications. It consists of both a signing and encryption key. AppSecurityKey workspaceapps.SecurityKey @@ -208,11 +209,20 @@ func New(options *Options) *API { if options.Authorizer == nil { options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry) } + + if options.AccessControlStore == nil { + options.AccessControlStore = &atomic.Pointer[dbauthz.AccessControlStore]{} + var tacs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{} + options.AccessControlStore.Store(&tacs) + } + options.Database = dbauthz.New( options.Database, options.Authorizer, options.Logger.Named("authz_querier"), + options.AccessControlStore, ) + experiments := ReadExperiments( options.Logger, options.DeploymentValues.Experiments.Value(), ) @@ -369,6 +379,7 @@ func New(options *Options) *API { Auditor: atomic.Pointer[audit.Auditor]{}, TemplateScheduleStore: options.TemplateScheduleStore, UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, + AccessControlStore: options.AccessControlStore, Experiments: experiments, healthCheckGroup: &singleflight.Group[string, *healthcheck.Report]{}, Acquirer: provisionerdserver.NewAcquirer( @@ -1008,6 +1019,9 @@ type API struct { UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] // DERPMapper mutates the DERPMap to include workspace proxies. DERPMapper atomic.Pointer[func(derpMap *tailcfg.DERPMap) *tailcfg.DERPMap] + // AccessControlStore is a pointer to an atomic pointer since it is + // passed to dbauthz. + AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore] HTTPAuth *HTTPAuthorizer diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 4eea44a226bfa..6310a48d04e2e 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -218,7 +218,6 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can if options.Database == nil { options.Database, options.Pubsub = dbtestutil.NewDB(t) - options.Database = dbauthz.New(options.Database, options.Authorizer, options.Logger.Leveled(slog.LevelDebug)) } // Some routes expect a deployment ID, so just make sure one exists. @@ -260,6 +259,10 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can t.Cleanup(closeBatcher) } + accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{} + var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{} + accessControlStore.Store(&acs) + var templateScheduleStore atomic.Pointer[schedule.TemplateScheduleStore] if options.TemplateScheduleStore == nil { options.TemplateScheduleStore = schedule.NewAGPLTemplateScheduleStore() @@ -279,6 +282,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can options.Pubsub, &templateScheduleStore, &auditor, + accessControlStore, *options.Logger, options.AutobuildTicker, ).WithStatsChannel(options.AutobuildStats) @@ -416,6 +420,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can Authorizer: options.Authorizer, Telemetry: telemetry.NewNoop(), TemplateScheduleStore: &templateScheduleStore, + AccessControlStore: accessControlStore, TLSCertificates: options.TLSCertificates, TrialGenerator: options.TrialGenerator, TailnetCoordinator: options.Coordinator, @@ -915,7 +920,7 @@ func CreateWorkspace(t testing.TB, client *codersdk.Client, organization uuid.UU } // TransitionWorkspace is a convenience method for transitioning a workspace from one state to another. -func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID uuid.UUID, from, to database.WorkspaceTransition) codersdk.Workspace { +func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID uuid.UUID, from, to database.WorkspaceTransition, muts ...func(req *codersdk.CreateWorkspaceBuildRequest)) codersdk.Workspace { t.Helper() ctx := context.Background() workspace, err := client.Workspace(ctx, workspaceID) @@ -925,10 +930,16 @@ func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID template, err := client.Template(ctx, workspace.TemplateID) require.NoError(t, err, "fetch workspace template") - build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + req := codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: template.ActiveVersionID, Transition: codersdk.WorkspaceTransition(to), - }) + } + + for _, mut := range muts { + mut(&req) + } + + build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, req) require.NoError(t, err, "unexpected error transitioning workspace to %s", to) _ = AwaitWorkspaceBuildJobCompleted(t, client, build.ID) diff --git a/coderd/database/dbauthz/accesscontrol.go b/coderd/database/dbauthz/accesscontrol.go new file mode 100644 index 0000000000000..92417ff4114ba --- /dev/null +++ b/coderd/database/dbauthz/accesscontrol.go @@ -0,0 +1,37 @@ +package dbauthz + +import ( + "context" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/database" +) + +// AccessControlStore fetches access control-related configuration +// that is used when determining whether an actor is authorized +// to interact with an RBAC object. +type AccessControlStore interface { + GetTemplateAccessControl(t database.Template) TemplateAccessControl + SetTemplateAccessControl(ctx context.Context, store database.Store, id uuid.UUID, opts TemplateAccessControl) error +} + +type TemplateAccessControl struct { + RequireActiveVersion bool +} + +// AGPLTemplateAccessControlStore always returns the defaults for access control +// settings. +type AGPLTemplateAccessControlStore struct{} + +var _ AccessControlStore = AGPLTemplateAccessControlStore{} + +func (AGPLTemplateAccessControlStore) GetTemplateAccessControl(database.Template) TemplateAccessControl { + return TemplateAccessControl{ + RequireActiveVersion: false, + } +} + +func (AGPLTemplateAccessControlStore) SetTemplateAccessControl(context.Context, database.Store, uuid.UUID, TemplateAccessControl) error { + return nil +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 038f4e0c92807..796c68a5031f8 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "sync/atomic" "time" "github.com/google/uuid" @@ -101,9 +102,10 @@ type querier struct { db database.Store auth rbac.Authorizer log slog.Logger + acs *atomic.Pointer[AccessControlStore] } -func New(db database.Store, authorizer rbac.Authorizer, logger slog.Logger) database.Store { +func New(db database.Store, authorizer rbac.Authorizer, logger slog.Logger, acs *atomic.Pointer[AccessControlStore]) database.Store { // If the underlying db store is already a querier, return it. // Do not double wrap. if slices.Contains(db.Wrappers(), wrapname) { @@ -113,6 +115,7 @@ func New(db database.Store, authorizer rbac.Authorizer, logger slog.Logger) data db: db, auth: authorizer, log: logger, + acs: acs, } } @@ -507,7 +510,7 @@ func (q *querier) Ping(ctx context.Context) (time.Duration, error) { func (q *querier) InTx(function func(querier database.Store) error, txOpts *sql.TxOptions) error { return q.db.InTx(func(tx database.Store) error { // Wrap the transaction store in a querier. - wrapped := New(tx, q.auth, q.log) + wrapped := New(tx, q.auth, q.log, q.acs) return function(wrapped) }, txOpts) } @@ -2200,7 +2203,7 @@ func (q *querier) InsertWorkspaceAppStats(ctx context.Context, arg database.Inse func (q *querier) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error { w, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID) if err != nil { - return err + return xerrors.Errorf("get workspace by id: %w", err) } var action rbac.Action = rbac.ActionUpdate @@ -2209,7 +2212,28 @@ func (q *querier) InsertWorkspaceBuild(ctx context.Context, arg database.InsertW } if err = q.authorizeContext(ctx, action, w.WorkspaceBuildRBAC(arg.Transition)); err != nil { - return err + return xerrors.Errorf("authorize context: %w", err) + } + + // If we're starting a workspace we need to check the template. + if arg.Transition == database.WorkspaceTransitionStart { + t, err := q.db.GetTemplateByID(ctx, w.TemplateID) + if err != nil { + return xerrors.Errorf("get template by id: %w", err) + } + + accessControl := (*q.acs.Load()).GetTemplateAccessControl(t) + + // If the template requires the active version we need to check if + // the user is a template admin. If they aren't and are attempting + // to use a non-active version then we must fail the request. + if accessControl.RequireActiveVersion { + if arg.TemplateVersionID != t.ActiveVersionID { + if err = q.authorizeContext(ctx, rbac.ActionUpdate, t); err != nil { + return xerrors.Errorf("cannot use non-active version: %w", err) + } + } + } } return q.db.InsertWorkspaceBuild(ctx, arg) @@ -2442,6 +2466,13 @@ func (q *querier) UpdateTemplateACLByID(ctx context.Context, arg database.Update return fetchAndExec(q.log, q.auth, rbac.ActionCreate, fetch, q.db.UpdateTemplateACLByID)(ctx, arg) } +func (q *querier) UpdateTemplateAccessControlByID(ctx context.Context, arg database.UpdateTemplateAccessControlByIDParams) error { + fetch := func(ctx context.Context, arg database.UpdateTemplateAccessControlByIDParams) (database.Template, error) { + return q.db.GetTemplateByID(ctx, arg.ID) + } + return update(q.log, q.auth, fetch, q.db.UpdateTemplateAccessControlByID)(ctx, arg) +} + func (q *querier) UpdateTemplateActiveVersionByID(ctx context.Context, arg database.UpdateTemplateActiveVersionByIDParams) error { fetch := func(ctx context.Context, arg database.UpdateTemplateActiveVersionByIDParams) (database.Template, error) { return q.db.GetTemplateByID(ctx, arg.ID) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 28866396c8b86..a5847839b2478 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -21,6 +21,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/testutil" ) func TestAsNoActor(t *testing.T) { @@ -61,7 +62,7 @@ func TestAsNoActor(t *testing.T) { func TestPing(t *testing.T) { t.Parallel() - q := dbauthz.New(dbfake.New(), &coderdtest.RecordingAuthorizer{}, slog.Make()) + q := dbauthz.New(dbfake.New(), &coderdtest.RecordingAuthorizer{}, slog.Make(), accessControlStorePointer()) _, err := q.Ping(context.Background()) require.NoError(t, err, "must not error") } @@ -73,7 +74,7 @@ func TestInTX(t *testing.T) { db := dbfake.New() q := dbauthz.New(db, &coderdtest.RecordingAuthorizer{ Wrapped: &coderdtest.FakeAuthorizer{AlwaysReturn: xerrors.New("custom error")}, - }, slog.Make()) + }, slog.Make(), accessControlStorePointer()) actor := rbac.Subject{ ID: uuid.NewString(), Roles: rbac.RoleNames{rbac.RoleOwner()}, @@ -109,8 +110,8 @@ func TestNew(t *testing.T) { // Double wrap should not cause an actual double wrap. So only 1 rbac call // should be made. - az := dbauthz.New(db, rec, slog.Make()) - az = dbauthz.New(az, rec, slog.Make()) + az := dbauthz.New(db, rec, slog.Make(), accessControlStorePointer()) + az = dbauthz.New(az, rec, slog.Make(), accessControlStorePointer()) w, err := az.GetWorkspaceByID(ctx, exp.ID) require.NoError(t, err, "must not error") @@ -127,7 +128,7 @@ func TestDBAuthzRecursive(t *testing.T) { t.Parallel() q := dbauthz.New(dbfake.New(), &coderdtest.RecordingAuthorizer{ Wrapped: &coderdtest.FakeAuthorizer{AlwaysReturn: nil}, - }, slog.Make()) + }, slog.Make(), accessControlStorePointer()) actor := rbac.Subject{ ID: uuid.NewString(), Roles: rbac.RoleNames{rbac.RoleOwner()}, @@ -1213,13 +1214,67 @@ func (s *MethodTestSuite) TestWorkspace() { }).Asserts(rbac.ResourceWorkspace.WithOwner(u.ID.String()).InOrg(o.ID), rbac.ActionCreate) })) s.Run("Start/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { - w := dbgen.Workspace(s.T(), db, database.Workspace{}) + t := dbgen.Template(s.T(), db, database.Template{}) + w := dbgen.Workspace(s.T(), db, database.Workspace{ + TemplateID: t.ID, + }) check.Args(database.InsertWorkspaceBuildParams{ WorkspaceID: w.ID, Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator, }).Asserts(w.WorkspaceBuildRBAC(database.WorkspaceTransitionStart), rbac.ActionUpdate) })) + s.Run("Start/RequireActiveVersion/VersionMismatch/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { + t := dbgen.Template(s.T(), db, database.Template{}) + ctx := testutil.Context(s.T(), testutil.WaitShort) + err := db.UpdateTemplateAccessControlByID(ctx, database.UpdateTemplateAccessControlByIDParams{ + ID: t.ID, + RequireActiveVersion: true, + }) + require.NoError(s.T(), err) + v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: t.ID}, + }) + w := dbgen.Workspace(s.T(), db, database.Workspace{ + TemplateID: t.ID, + }) + check.Args(database.InsertWorkspaceBuildParams{ + WorkspaceID: w.ID, + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + TemplateVersionID: v.ID, + }).Asserts( + w.WorkspaceBuildRBAC(database.WorkspaceTransitionStart), rbac.ActionUpdate, + t, rbac.ActionUpdate, + ) + })) + s.Run("Start/RequireActiveVersion/VersionsMatch/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { + v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{}) + t := dbgen.Template(s.T(), db, database.Template{ + ActiveVersionID: v.ID, + }) + + ctx := testutil.Context(s.T(), testutil.WaitShort) + err := db.UpdateTemplateAccessControlByID(ctx, database.UpdateTemplateAccessControlByIDParams{ + ID: t.ID, + RequireActiveVersion: true, + }) + require.NoError(s.T(), err) + + w := dbgen.Workspace(s.T(), db, database.Workspace{ + TemplateID: t.ID, + }) + // Assert that we do not check for template update permissions + // if versions match. + check.Args(database.InsertWorkspaceBuildParams{ + WorkspaceID: w.ID, + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + TemplateVersionID: v.ID, + }).Asserts( + w.WorkspaceBuildRBAC(database.WorkspaceTransitionStart), rbac.ActionUpdate, + ) + })) s.Run("Delete/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { w := dbgen.Workspace(s.T(), db, database.Workspace{}) check.Args(database.InsertWorkspaceBuildParams{ diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index 9efcf5ef9418e..968b882f2d263 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -6,6 +6,7 @@ import ( "reflect" "sort" "strings" + "sync/atomic" "testing" "github.com/golang/mock/gomock" @@ -59,7 +60,7 @@ func (s *MethodTestSuite) SetupSuite() { mockStore := dbmock.NewMockStore(ctrl) // We intentionally set no expectations apart from this. mockStore.EXPECT().Wrappers().Return([]string{}).AnyTimes() - az := dbauthz.New(mockStore, nil, slog.Make()) + az := dbauthz.New(mockStore, nil, slog.Make(), accessControlStorePointer()) // Take the underlying type of the interface. azt := reflect.TypeOf(az).Elem() s.methodAccounting = make(map[string]int) @@ -110,7 +111,7 @@ func (s *MethodTestSuite) Subtest(testCaseF func(db database.Store, check *expec rec := &coderdtest.RecordingAuthorizer{ Wrapped: fakeAuthorizer, } - az := dbauthz.New(db, rec, slog.Make()) + az := dbauthz.New(db, rec, slog.Make(), accessControlStorePointer()) actor := rbac.Subject{ ID: uuid.NewString(), Roles: rbac.RoleNames{rbac.RoleOwner()}, @@ -398,3 +399,22 @@ func (emptyPreparedAuthorized) Authorize(_ context.Context, _ rbac.Object) error func (emptyPreparedAuthorized) CompileToSQL(_ context.Context, _ regosql.ConvertConfig) (string, error) { return "", nil } + +func accessControlStorePointer() *atomic.Pointer[dbauthz.AccessControlStore] { + acs := &atomic.Pointer[dbauthz.AccessControlStore]{} + var tacs dbauthz.AccessControlStore = fakeAccessControlStore{} + acs.Store(&tacs) + return acs +} + +type fakeAccessControlStore struct{} + +func (fakeAccessControlStore) GetTemplateAccessControl(t database.Template) dbauthz.TemplateAccessControl { + return dbauthz.TemplateAccessControl{ + RequireActiveVersion: t.RequireActiveVersion, + } +} + +func (fakeAccessControlStore) SetTemplateAccessControl(context.Context, database.Store, uuid.UUID, dbauthz.TemplateAccessControl) error { + panic("not implemented") +} diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index e138584498d37..2d46bbe76ec9a 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -5643,6 +5643,25 @@ func (q *FakeQuerier) UpdateTemplateACLByID(_ context.Context, arg database.Upda return sql.ErrNoRows } +func (q *FakeQuerier) UpdateTemplateAccessControlByID(_ context.Context, arg database.UpdateTemplateAccessControlByIDParams) error { + if err := validateDatabaseType(arg); err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for idx, tpl := range q.templates { + if tpl.ID != arg.ID { + continue + } + q.templates[idx].RequireActiveVersion = arg.RequireActiveVersion + return nil + } + + return sql.ErrNoRows +} + func (q *FakeQuerier) UpdateTemplateActiveVersionByID(_ context.Context, arg database.UpdateTemplateActiveVersionByIDParams) error { if err := validateDatabaseType(arg); err != nil { return err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index ece7020139b0f..ed3ff65b18378 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1523,6 +1523,13 @@ func (m metricsStore) UpdateTemplateACLByID(ctx context.Context, arg database.Up return err } +func (m metricsStore) UpdateTemplateAccessControlByID(ctx context.Context, arg database.UpdateTemplateAccessControlByIDParams) error { + start := time.Now() + r0 := m.s.UpdateTemplateAccessControlByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTemplateAccessControlByID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) UpdateTemplateActiveVersionByID(ctx context.Context, arg database.UpdateTemplateActiveVersionByIDParams) error { start := time.Now() err := m.s.UpdateTemplateActiveVersionByID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 31614be3ae919..491937c6aeb6d 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3213,6 +3213,20 @@ func (mr *MockStoreMockRecorder) UpdateTemplateACLByID(arg0, arg1 interface{}) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateACLByID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateACLByID), arg0, arg1) } +// UpdateTemplateAccessControlByID mocks base method. +func (m *MockStore) UpdateTemplateAccessControlByID(arg0 context.Context, arg1 database.UpdateTemplateAccessControlByIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTemplateAccessControlByID", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTemplateAccessControlByID indicates an expected call of UpdateTemplateAccessControlByID. +func (mr *MockStoreMockRecorder) UpdateTemplateAccessControlByID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateAccessControlByID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateAccessControlByID), arg0, arg1) +} + // UpdateTemplateActiveVersionByID mocks base method. func (m *MockStore) UpdateTemplateActiveVersionByID(arg0 context.Context, arg1 database.UpdateTemplateActiveVersionByIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ab6eb95252f2d..0e79c875f93bc 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -755,7 +755,8 @@ CREATE TABLE templates ( time_til_dormant_autodelete bigint DEFAULT 0 NOT NULL, autostop_requirement_days_of_week smallint DEFAULT 0 NOT NULL, autostop_requirement_weeks bigint DEFAULT 0 NOT NULL, - autostart_block_days_of_week smallint DEFAULT 0 NOT NULL + autostart_block_days_of_week smallint DEFAULT 0 NOT NULL, + require_active_version boolean DEFAULT false NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.'; @@ -800,6 +801,7 @@ CREATE VIEW template_with_users AS templates.autostop_requirement_days_of_week, templates.autostop_requirement_weeks, templates.autostart_block_days_of_week, + templates.require_active_version, COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, COALESCE(visible_users.username, ''::text) AS created_by_username FROM (public.templates diff --git a/coderd/database/migrations/000166_template_active_version.down.sql b/coderd/database/migrations/000166_template_active_version.down.sql new file mode 100644 index 0000000000000..d3b4bba305e02 --- /dev/null +++ b/coderd/database/migrations/000166_template_active_version.down.sql @@ -0,0 +1,25 @@ +BEGIN; + +-- Update the template_with_users view; +DROP VIEW template_with_users; + +ALTER TABLE templates DROP COLUMN require_active_version; + +-- If you need to update this view, put 'DROP VIEW template_with_users;' before this. +CREATE VIEW + template_with_users +AS + SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username + FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; + +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; + +COMMIT; diff --git a/coderd/database/migrations/000166_template_active_version.up.sql b/coderd/database/migrations/000166_template_active_version.up.sql new file mode 100644 index 0000000000000..a9255505eede7 --- /dev/null +++ b/coderd/database/migrations/000166_template_active_version.up.sql @@ -0,0 +1,23 @@ +BEGIN; + +DROP VIEW template_with_users; + +ALTER TABLE templates ADD COLUMN require_active_version boolean NOT NULL DEFAULT 'f'; + +CREATE VIEW + template_with_users +AS + SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username + FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; + +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; + +COMMIT; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 66e1618bf570f..ed5bf76b784d7 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -179,7 +179,7 @@ func (w Workspace) ApplicationConnectRBAC() rbac.Object { } func (w Workspace) WorkspaceBuildRBAC(transition WorkspaceTransition) rbac.Object { - // If a workspace is locked it cannot be built. + // If a workspace is dormant it cannot be built. // However we need to allow stopping a workspace by a caller once a workspace // is locked (e.g. for autobuild). Additionally, if a user wants to delete // a locked workspace, they shouldn't have to have it unlocked first. diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index bb380a8293bdc..5c78600237e1d 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -86,6 +86,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.AutostopRequirementDaysOfWeek, &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, + &i.RequireActiveVersion, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { diff --git a/coderd/database/models.go b/coderd/database/models.go index 1684e4fe5ebeb..c67afa74faa9a 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1892,6 +1892,7 @@ type Template struct { AutostopRequirementDaysOfWeek int16 `db:"autostop_requirement_days_of_week" json:"autostop_requirement_days_of_week"` AutostopRequirementWeeks int64 `db:"autostop_requirement_weeks" json:"autostop_requirement_weeks"` AutostartBlockDaysOfWeek int16 `db:"autostart_block_days_of_week" json:"autostart_block_days_of_week"` + RequireActiveVersion bool `db:"require_active_version" json:"require_active_version"` CreatedByAvatarURL sql.NullString `db:"created_by_avatar_url" json:"created_by_avatar_url"` CreatedByUsername string `db:"created_by_username" json:"created_by_username"` } @@ -1930,6 +1931,7 @@ type TemplateTable struct { AutostopRequirementWeeks int64 `db:"autostop_requirement_weeks" json:"autostop_requirement_weeks"` // A bitmap of days of week that autostart of a workspace is not allowed. Default allows all days. This is intended as a cost savings measure to prevent auto start on weekends (for example). AutostartBlockDaysOfWeek int16 `db:"autostart_block_days_of_week" json:"autostart_block_days_of_week"` + RequireActiveVersion bool `db:"require_active_version" json:"require_active_version"` } // Joins in the username + avatar url of the created by user. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 99503ba40e3d6..bb2e95d17c309 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -301,6 +301,7 @@ type sqlcQuerier interface { UpdateProvisionerJobWithCompleteByID(ctx context.Context, arg UpdateProvisionerJobWithCompleteByIDParams) error UpdateReplica(ctx context.Context, arg UpdateReplicaParams) (Replica, error) UpdateTemplateACLByID(ctx context.Context, arg UpdateTemplateACLByIDParams) error + UpdateTemplateAccessControlByID(ctx context.Context, arg UpdateTemplateAccessControlByIDParams) error UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fc301d427fa8d..d4978e5d141e8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4726,7 +4726,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem 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, max_ttl, 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, created_by_avatar_url, created_by_username + 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, max_ttl, 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, created_by_avatar_url, created_by_username FROM template_with_users WHERE @@ -4764,6 +4764,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.AutostopRequirementDaysOfWeek, &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, + &i.RequireActiveVersion, &i.CreatedByAvatarURL, &i.CreatedByUsername, ) @@ -4772,7 +4773,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, max_ttl, 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, created_by_avatar_url, created_by_username + 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, max_ttl, 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, created_by_avatar_url, created_by_username FROM template_with_users AS templates WHERE @@ -4818,6 +4819,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.AutostopRequirementDaysOfWeek, &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, + &i.RequireActiveVersion, &i.CreatedByAvatarURL, &i.CreatedByUsername, ) @@ -4825,7 +4827,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, max_ttl, 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, created_by_avatar_url, created_by_username FROM template_with_users 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, max_ttl, 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, created_by_avatar_url, created_by_username FROM template_with_users AS templates ORDER BY (name, id) ASC ` @@ -4864,6 +4866,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.AutostopRequirementDaysOfWeek, &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, + &i.RequireActiveVersion, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -4882,7 +4885,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :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, max_ttl, 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, created_by_avatar_url, created_by_username + 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, max_ttl, 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, created_by_avatar_url, created_by_username FROM template_with_users AS templates WHERE @@ -4958,6 +4961,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.AutostopRequirementDaysOfWeek, &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, + &i.RequireActiveVersion, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -5054,6 +5058,25 @@ func (q *sqlQuerier) UpdateTemplateACLByID(ctx context.Context, arg UpdateTempla return err } +const updateTemplateAccessControlByID = `-- name: UpdateTemplateAccessControlByID :exec +UPDATE + templates +SET + require_active_version = $2 +WHERE + id = $1 +` + +type UpdateTemplateAccessControlByIDParams struct { + ID uuid.UUID `db:"id" json:"id"` + RequireActiveVersion bool `db:"require_active_version" json:"require_active_version"` +} + +func (q *sqlQuerier) UpdateTemplateAccessControlByID(ctx context.Context, arg UpdateTemplateAccessControlByIDParams) error { + _, err := q.db.ExecContext(ctx, updateTemplateAccessControlByID, arg.ID, arg.RequireActiveVersion) + return err +} + const updateTemplateActiveVersionByID = `-- name: UpdateTemplateActiveVersionByID :exec UPDATE templates diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index e89c245b0391d..c5bc72d7911d6 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -169,3 +169,12 @@ SELECT coalesce((PERCENTILE_DISC(0.95) WITHIN GROUP(ORDER BY exec_time_sec) FILTER (WHERE transition = 'delete')), -1)::FLOAT AS delete_95 FROM build_times ; + +-- name: UpdateTemplateAccessControlByID :exec +UPDATE + templates +SET + require_active_version = $2 +WHERE + id = $1 +; diff --git a/coderd/templates.go b/coderd/templates.go index 63fec1c63c9d6..c1f3bc97a01c3 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -347,6 +347,15 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque return xerrors.Errorf("insert template: %s", err) } + if createTemplate.RequireActiveVersion { + err = (*api.AccessControlStore.Load()).SetTemplateAccessControl(ctx, tx, id, dbauthz.TemplateAccessControl{ + RequireActiveVersion: createTemplate.RequireActiveVersion, + }) + if err != nil { + return xerrors.Errorf("set template access control: %w", err) + } + } + dbTemplate, err = tx.GetTemplateByID(ctx, id) if err != nil { return xerrors.Errorf("get template by id: %s", err) @@ -614,7 +623,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.AutostopRequirement.Weeks == scheduleOpts.AutostopRequirement.Weeks && req.FailureTTLMillis == time.Duration(template.FailureTTL).Milliseconds() && req.TimeTilDormantMillis == time.Duration(template.TimeTilDormant).Milliseconds() && - req.TimeTilDormantAutoDeleteMillis == time.Duration(template.TimeTilDormantAutoDelete).Milliseconds() { + req.TimeTilDormantAutoDeleteMillis == time.Duration(template.TimeTilDormantAutoDelete).Milliseconds() && + req.RequireActiveVersion == template.RequireActiveVersion { return nil } @@ -638,6 +648,15 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("update template metadata: %w", err) } + if template.RequireActiveVersion != req.RequireActiveVersion { + err = (*api.AccessControlStore.Load()).SetTemplateAccessControl(ctx, tx, template.ID, dbauthz.TemplateAccessControl{ + RequireActiveVersion: req.RequireActiveVersion, + }) + if err != nil { + return xerrors.Errorf("set template access control: %w", err) + } + } + updated, err = tx.GetTemplateByID(ctx, template.ID) if err != nil { return xerrors.Errorf("fetch updated template metadata: %w", err) @@ -824,5 +843,6 @@ func (api *API) convertTemplate( AutostartRequirement: codersdk.TemplateAutostartRequirement{ DaysOfWeek: codersdk.BitmapToWeekdays(template.AutostartAllowedDays()), }, + RequireActiveVersion: template.RequireActiveVersion, } } diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index 66e784d8462b6..80d5cca80f313 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -11,7 +11,6 @@ import ( "time" "github.com/google/uuid" - "github.com/lib/pq" "github.com/sqlc-dev/pqtype" "golang.org/x/xerrors" @@ -216,28 +215,18 @@ func (b *Builder) Build( // RepeatableRead isolation ensures that we get a consistent view of the database while // computing the new build. This simplifies the logic so that we do not need to worry if // later reads are consistent with earlier ones. - for retries := 0; retries < 5; retries++ { - var workspaceBuild *database.WorkspaceBuild - var provisionerJob *database.ProvisionerJob - err := store.InTx(func(store database.Store) error { - b.store = store - workspaceBuild, provisionerJob, err = b.buildTx(authFunc) - return err - }, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) - var pqe *pq.Error - if xerrors.As(err, &pqe) { - if pqe.Code == "40001" { - // serialization error, retry - continue - } - } - if err != nil { - // Other (hard) error - return nil, nil, err - } - return workspaceBuild, provisionerJob, nil + var workspaceBuild *database.WorkspaceBuild + var provisionerJob *database.ProvisionerJob + err = database.ReadModifyUpdate(store, func(tx database.Store) error { + var err error + b.store = tx + workspaceBuild, provisionerJob, err = b.buildTx(authFunc) + return err + }) + if err != nil { + return nil, nil, xerrors.Errorf("build tx: %w", err) } - return nil, nil, xerrors.Errorf("too many errors; last error: %w", err) + return workspaceBuild, provisionerJob, nil } // buildTx contains the business logic of computing a new build. Attributes of the new database objects are computed @@ -360,7 +349,11 @@ func (b *Builder) buildTx(authFunc func(action rbac.Action, object rbac.Objecter MaxDeadline: time.Time{}, // set by provisioner upon completion }) if err != nil { - return BuildError{http.StatusInternalServerError, "insert workspace build", err} + code := http.StatusInternalServerError + if rbac.IsUnauthorizedError(err) { + code = http.StatusUnauthorized + } + return BuildError{code, "insert workspace build", err} } names, values, err := b.getParameters() diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 4622808853aa5..bfcead815cf7c 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -50,6 +50,7 @@ const ( FeatureExternalTokenEncryption FeatureName = "external_token_encryption" FeatureTemplateAutostopRequirement FeatureName = "template_autostop_requirement" FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions" + FeatureAccessControl FeatureName = "access_control" ) // FeatureNames must be kept in-sync with the Feature enum above. @@ -70,6 +71,7 @@ var FeatureNames = []FeatureName{ FeatureExternalTokenEncryption, FeatureTemplateAutostopRequirement, FeatureWorkspaceBatchActions, + FeatureAccessControl, } // Humanize returns the feature name in a human-readable format. diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 6c10bc2c91abe..cc206180f81ae 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -124,6 +124,10 @@ type CreateTemplateRequest struct { // and must be explicitly granted to users or groups in the permissions settings // of the template. DisableEveryoneGroupAccess bool `json:"disable_everyone_group_access"` + + // RequireActiveVersion mandates that workspaces are built with the active + // template version. + RequireActiveVersion bool `json:"require_active_version"` } // CreateWorkspaceRequest provides options for creating a new workspace. diff --git a/codersdk/templates.go b/codersdk/templates.go index d0ee400a29b20..3a3240ca711b2 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -52,6 +52,10 @@ type Template struct { FailureTTLMillis int64 `json:"failure_ttl_ms"` TimeTilDormantMillis int64 `json:"time_til_dormant_ms"` TimeTilDormantAutoDeleteMillis int64 `json:"time_til_dormant_autodelete_ms"` + + // RequireActiveVersion mandates that workspaces are built with the active + // template version. + RequireActiveVersion bool `json:"require_active_version"` } // WeekdaysToBitmap converts a list of weekdays to a bitmap in accordance with @@ -221,6 +225,10 @@ type UpdateTemplateMeta struct { // from the template. This is useful for preventing dormant workspaces being immediately // deleted when updating the dormant_ttl field to a new, shorter value. UpdateWorkspaceDormantAt bool `json:"update_workspace_dormant_at"` + // RequireActiveVersion mandates workspaces built using this template + // use the active version of the template. This option has no + // effect on template admins. + RequireActiveVersion bool `json:"require_active_version"` } type TemplateExample struct { diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index c7da38bec58d1..af7a5724458d7 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -8,19 +8,19 @@ We track the following resources: -| Resource | | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| -| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| +| Resource | | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| +| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| diff --git a/docs/api/schemas.md b/docs/api/schemas.md index cd09260f59546..da6715839647a 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1584,6 +1584,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "icon": "string", "max_ttl_ms": 0, "name": "string", + "require_active_version": true, "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1" } ``` @@ -1607,6 +1608,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. | | `max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once autostop_requirement is matured | | `name` | string | true | | Name is the name of the template. | +| `require_active_version` | boolean | false | | Require active version mandates that workspaces are built with the active template version. | | `template_version_id` | string | true | | Template version ID is an in-progress or completed job to use as an initial version of the template. | | This is required on creation to enable a user-flow of validating a template works. There is no reason the data-model cannot support empty templates, but it doesn't make sense for users. | @@ -4370,6 +4372,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "require_active_version": true, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" @@ -4401,6 +4404,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `name` | string | false | | | | `organization_id` | string | false | | | | `provisioner` | string | false | | | +| `require_active_version` | boolean | false | | Require active version mandates that workspaces are built with the active template version. | | `time_til_dormant_autodelete_ms` | integer | false | | | | `time_til_dormant_ms` | integer | false | | | | `updated_at` | string | false | | | diff --git a/docs/api/templates.md b/docs/api/templates.md index a6cc2d4b5a367..08de540e2a7f2 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -61,6 +61,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "require_active_version": true, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" @@ -109,6 +110,7 @@ Status Code **200** | `» name` | string | false | | | | `» organization_id` | string(uuid) | false | | | | `» provisioner` | string | false | | | +| `» require_active_version` | boolean | false | | Require active version mandates that workspaces are built with the active template version. | | `» time_til_dormant_autodelete_ms` | integer | false | | | | `» time_til_dormant_ms` | integer | false | | | | `» updated_at` | string(date-time) | false | | | @@ -159,6 +161,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "icon": "string", "max_ttl_ms": 0, "name": "string", + "require_active_version": true, "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1" } ``` @@ -211,6 +214,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "require_active_version": true, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" @@ -346,6 +350,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "require_active_version": true, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" @@ -657,6 +662,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "require_active_version": true, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" @@ -775,6 +781,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "require_active_version": true, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 49f10d117057c..f272354d649ac 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -85,6 +85,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "failure_ttl": ActionTrack, "time_til_dormant": ActionTrack, "time_til_dormant_autodelete": ActionTrack, + "require_active_version": ActionTrack, }, &database.TemplateVersion{}: { "id": ActionTrack, diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index b49890589a6c3..55defaf9b964d 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -24,12 +24,13 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/coderd" agplaudit "github.com/coder/coder/v2/coderd/audit" - "github.com/coder/coder/v2/coderd/database/dbauthz" + agpldbauthz "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" agplschedule "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/dbauthz" "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/coder/v2/enterprise/coderd/proxyhealth" "github.com/coder/coder/v2/enterprise/coderd/schedule" @@ -113,7 +114,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { api.AGPL.SiteHandler.RegionsFetcher = func(ctx context.Context) (any, error) { // If the user can read the workspace proxy resource, return that. // If not, always default to the regions. - actor, ok := dbauthz.ActorFromContext(ctx) + actor, ok := agpldbauthz.ActorFromContext(ctx) if ok && api.Authorizer.Authorize(ctx, actor, rbac.ActionRead, rbac.ResourceWorkspaceProxy) == nil { return api.fetchWorkspaceProxies(ctx) } @@ -482,6 +483,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureTemplateAutostopRequirement: api.AGPL.Experiments.Enabled(codersdk.ExperimentTemplateAutostopRequirement) && api.DefaultQuietHoursSchedule != "", codersdk.FeatureWorkspaceProxy: true, codersdk.FeatureUserRoleManagement: true, + codersdk.FeatureAccessControl: true, }) if err != nil { return err @@ -654,6 +656,14 @@ func (api *API) updateEntitlements(ctx context.Context) error { } } + if initial, changed, enabled := featureChanged(codersdk.FeatureAccessControl); shouldUpdate(initial, changed, enabled) { + var acs agpldbauthz.AccessControlStore = agpldbauthz.AGPLTemplateAccessControlStore{} + if enabled { + acs = dbauthz.EnterpriseTemplateAccessControlStore{} + } + api.AGPL.AccessControlStore.Store(&acs) + } + // External token encryption is soft-enforced featureExternalTokenEncryption := entitlements.Features[codersdk.FeatureExternalTokenEncryption] featureExternalTokenEncryption.Enabled = len(api.ExternalTokenEncryption) > 0 diff --git a/enterprise/coderd/dbauthz/accesscontrol.go b/enterprise/coderd/dbauthz/accesscontrol.go new file mode 100644 index 0000000000000..454f416ab8736 --- /dev/null +++ b/enterprise/coderd/dbauthz/accesscontrol.go @@ -0,0 +1,30 @@ +package dbauthz + +import ( + "context" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + agpldbz "github.com/coder/coder/v2/coderd/database/dbauthz" +) + +type EnterpriseTemplateAccessControlStore struct{} + +func (EnterpriseTemplateAccessControlStore) GetTemplateAccessControl(t database.Template) agpldbz.TemplateAccessControl { + return agpldbz.TemplateAccessControl{ + RequireActiveVersion: t.RequireActiveVersion, + } +} + +func (EnterpriseTemplateAccessControlStore) SetTemplateAccessControl(ctx context.Context, store database.Store, id uuid.UUID, opts agpldbz.TemplateAccessControl) error { + err := store.UpdateTemplateAccessControlByID(ctx, database.UpdateTemplateAccessControlByIDParams{ + ID: id, + RequireActiveVersion: opts.RequireActiveVersion, + }) + if err != nil { + return xerrors.Errorf("update template access control: %w", err) + } + return nil +} diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 50bd6ecf0798b..6b51e706e2d81 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/enterprise/coderd/schedule" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/testutil" ) @@ -571,6 +572,51 @@ func TestTemplates(t *testing.T) { require.Equal(t, updatedDormantWS.DormantAt, dormantWorkspace.DormantAt) require.True(t, updatedDormantWS.LastUsedAt.After(dormantWorkspace.LastUsedAt)) }) + + t.Run("RequireActiveVersion", func(t *testing.T) { + t.Parallel() + client, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAccessControl: 1, + }, + }, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.RequireActiveVersion = true + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + require.True(t, template.RequireActiveVersion) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Update the field and assert it persists. + updatedTemplate, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + RequireActiveVersion: false, + }) + require.NoError(t, err) + require.False(t, updatedTemplate.RequireActiveVersion) + + // Flip it back to ensure we aren't hardcoding to a default value. + updatedTemplate, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + RequireActiveVersion: true, + }) + require.NoError(t, err) + require.True(t, updatedTemplate.RequireActiveVersion) + + // Assert that fetching a template is no different from the response + // when updating. + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + require.Equal(t, updatedTemplate, template) + }) } func TestTemplateACL(t *testing.T) { diff --git a/enterprise/coderd/workspacebuilds_test.go b/enterprise/coderd/workspacebuilds_test.go new file mode 100644 index 0000000000000..615991a65dec9 --- /dev/null +++ b/enterprise/coderd/workspacebuilds_test.go @@ -0,0 +1,139 @@ +package coderd_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestWorkspaceBuild(t *testing.T) { + t.Parallel() + t.Run("TemplateRequiresActiveVersion", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAccessControl: 1, + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }, + }) + + // Create an initial version. + oldVersion := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, nil) + // Create a template that mandates the promoted version. + // This should be enforced for everyone except template admins. + template := coderdtest.CreateTemplate(t, ownerClient, owner.OrganizationID, oldVersion.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, oldVersion.ID) + require.Equal(t, oldVersion.ID, template.ActiveVersionID) + template, err := ownerClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + RequireActiveVersion: true, + }) + require.NoError(t, err) + require.True(t, template.RequireActiveVersion) + + // Create a new version that we will promote. + activeVersion := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, activeVersion.ID) + err = ownerClient.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: activeVersion.ID, + }) + require.NoError(t, err) + + templateAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) + templateACLAdminClient, templateACLAdmin := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + templateGroupACLAdminClient, templateGroupACLAdmin := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + // Create a group so we can also test group template admin ownership. + group, err := ownerClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "test", + }) + require.NoError(t, err) + + // Add the user who gains template admin via group membership. + group, err = ownerClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{templateGroupACLAdmin.ID.String()}, + }) + require.NoError(t, err) + + // Update the template for both users and groups. + err = ownerClient.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + templateACLAdmin.ID.String(): codersdk.TemplateRoleAdmin, + }, + GroupPerms: map[string]codersdk.TemplateRole{ + group.ID.String(): codersdk.TemplateRoleAdmin, + }, + }) + require.NoError(t, err) + + type testcase struct { + Name string + Client *codersdk.Client + ExpectedStatusCode int + } + + cases := []testcase{ + { + Name: "OwnerOK", + Client: ownerClient, + ExpectedStatusCode: http.StatusOK, + }, + { + Name: "TemplateAdminOK", + Client: templateAdminClient, + ExpectedStatusCode: http.StatusOK, + }, + { + Name: "TemplateACLAdminOK", + Client: templateACLAdminClient, + ExpectedStatusCode: http.StatusOK, + }, + { + Name: "TemplateGroupACLAdminOK", + Client: templateGroupACLAdminClient, + ExpectedStatusCode: http.StatusOK, + }, + { + Name: "MemberFails", + Client: memberClient, + ExpectedStatusCode: http.StatusUnauthorized, + }, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + _, err = c.Client.CreateWorkspace(ctx, owner.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateVersionID: oldVersion.ID, + Name: "abc123", + AutomaticUpdates: codersdk.AutomaticUpdatesNever, + }) + if c.ExpectedStatusCode == http.StatusOK { + require.NoError(t, err) + } else { + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, c.ExpectedStatusCode, cerr.StatusCode()) + } + }) + } + }) +} diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index e38d1874e764d..a80107d379e74 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -734,6 +734,96 @@ func TestWorkspaceAutobuild(t *testing.T) { require.Len(t, stats.Transitions, 1) require.Equal(t, database.WorkspaceTransitionDelete, stats.Transitions[ws.ID]) }) + + t.Run("RequireActiveVersion", func(t *testing.T) { + t.Parallel() + + var ( + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + ctx = testutil.Context(t, testutil.WaitMedium) + ) + + client, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + AutobuildTicker: tickCh, + IncludeProvisionerDaemon: true, + AutobuildStats: statsCh, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureAccessControl: 1}, + }, + }) + + sched, err := cron.Weekly("CRON_TZ=UTC 0 * * * *") + require.NoError(t, err) + + // Create a template version1 that passes to get a functioning workspace. + version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) + + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) + require.Equal(t, version1.ID, template.ActiveVersionID) + + ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = ptr.Ref(sched.String()) + }) + + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + + // Create a new version so that we can assert we don't update + // to the latest by default. + version2 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID) + + // Make sure to promote it. + err = client.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: version2.ID, + }) + require.NoError(t, err) + + // Kick of an autostart build. + tickCh <- sched.Next(ws.LatestBuild.CreatedAt) + stats := <-statsCh + require.NoError(t, stats.Error) + require.Len(t, stats.Transitions, 1) + require.Contains(t, stats.Transitions, ws.ID) + require.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[ws.ID]) + + // Validate that we didn't update to the promoted version. + started := coderdtest.MustWorkspace(t, client, ws.ID) + firstBuild := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, started.LatestBuild.ID) + require.Equal(t, version1.ID, firstBuild.TemplateVersionID) + + // Update the template to require the promoted version. + _, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + RequireActiveVersion: true, + AllowUserAutostart: true, + }) + require.NoError(t, err) + + // Reset the workspace to the stopped state so we can try + // to autostart again. + coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) { + req.TemplateVersionID = ws.LatestBuild.TemplateVersionID + }) + + // Force an autostart transition again. + tickCh <- sched.Next(firstBuild.CreatedAt) + stats = <-statsCh + require.NoError(t, stats.Error) + require.Len(t, stats.Transitions, 1) + require.Contains(t, stats.Transitions, ws.ID) + require.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[ws.ID]) + + // Validate that we are using the promoted version. + ws = coderdtest.MustWorkspace(t, client, ws.ID) + require.Equal(t, version2.ID, ws.LatestBuild.TemplateVersionID) + }) } // Blocked by autostart requirements diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 29fbd56f5e316..805ab0d2bacf9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -221,6 +221,7 @@ export interface CreateTemplateRequest { readonly dormant_ttl_ms?: number; readonly delete_ttl_ms?: number; readonly disable_everyone_group_access: boolean; + readonly require_active_version: boolean; } // From codersdk/templateversions.go @@ -916,6 +917,7 @@ export interface Template { readonly failure_ttl_ms: number; readonly time_til_dormant_ms: number; readonly time_til_dormant_autodelete_ms: number; + readonly require_active_version: boolean; } // From codersdk/templates.go @@ -1166,6 +1168,7 @@ export interface UpdateTemplateMeta { readonly time_til_dormant_autodelete_ms?: number; readonly update_workspace_last_used_at: boolean; readonly update_workspace_dormant_at: boolean; + readonly require_active_version: boolean; } // From codersdk/users.go @@ -1715,6 +1718,7 @@ export const Experiments: Experiment[] = [ // From codersdk/deployment.go export type FeatureName = + | "access_control" | "advanced_template_scheduling" | "appearance" | "audit_log" @@ -1731,6 +1735,7 @@ export type FeatureName = | "workspace_batch_actions" | "workspace_proxy"; export const FeatureNames: FeatureName[] = [ + "access_control", "advanced_template_scheduling", "appearance", "audit_log", diff --git a/site/src/pages/CreateTemplatePage/utils.ts b/site/src/pages/CreateTemplatePage/utils.ts index 2e525eae7a29e..fdeb1046d3869 100644 --- a/site/src/pages/CreateTemplatePage/utils.ts +++ b/site/src/pages/CreateTemplatePage/utils.ts @@ -37,6 +37,7 @@ export const newTemplate = (formData: CreateTemplateData) => { autostart_requirement: { days_of_week: autostart_requirement_days_of_week, }, + require_active_version: false, }; }; diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx index 09f376a83420e..d6afac0fbd504 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx @@ -69,6 +69,7 @@ export const TemplateSettingsForm: FC = ({ template.allow_user_cancel_workspace_jobs, update_workspace_last_used_at: false, update_workspace_dormant_at: false, + require_active_version: false, }, validationSchema, onSubmit, diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index 8df20b8c3d399..c9f2059672fce 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -43,6 +43,7 @@ const validFormValues: FormValues = { time_til_dormant_autodelete_ms: 0, update_workspace_last_used_at: false, update_workspace_dormant_at: false, + require_active_version: false, }; const renderTemplateSettingsPage = async () => { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 54d558467753d..88cfcc394bdf7 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -113,6 +113,7 @@ export const TemplateScheduleForm: FC = ({ Boolean(template.time_til_dormant_autodelete_ms), update_workspace_last_used_at: false, update_workspace_dormant_at: false, + require_active_version: false, }, validationSchema, onSubmit: () => { @@ -229,6 +230,7 @@ export const TemplateScheduleForm: FC = ({ allow_user_autostop: form.values.allow_user_autostop, update_workspace_last_used_at: form.values.update_workspace_last_used_at, update_workspace_dormant_at: form.values.update_workspace_dormant_at, + require_active_version: false, }); }; diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index c2a520562a53b..f6096ab4e5718 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -26,6 +26,7 @@ const validFormValues: TemplateScheduleFormValues = { failure_cleanup_enabled: false, inactivity_cleanup_enabled: false, dormant_autodeletion_cleanup_enabled: false, + require_active_version: false, autostart_requirement_days_of_week: [ "monday", "tuesday", diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 35882aa8dcba4..7cf0e8c8a6c28 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -467,6 +467,7 @@ export const MockTemplate: TypesGen.Template = { time_til_dormant_autodelete_ms: 0, allow_user_autostart: true, allow_user_autostop: true, + require_active_version: false, }; export const MockTemplateVersionFiles: TemplateVersionFiles = {