feat: add template setting to require active template version (#10277)

This commit is contained in:
Jon Ayers
2023-10-18 17:07:21 -05:00
committed by GitHub
parent 1ad998ee3a
commit 997493d4ae
47 changed files with 802 additions and 70 deletions
+1 -1
View File
@@ -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())
-2
View File
@@ -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"
-2
View File
@@ -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"
-2
View File
@@ -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"
+8
View File
@@ -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"
},
+8
View File
@@ -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"
},
+14 -2
View File
@@ -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
}
@@ -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.
+14
View File
@@ -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
+15 -4
View File
@@ -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)
+37
View File
@@ -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
}
+35 -4
View File
@@ -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)
+61 -6
View File
@@ -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{
+22 -2
View File
@@ -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")
}
+19
View File
@@ -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
+7
View File
@@ -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)
+14
View File
@@ -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()
+3 -1
View File
@@ -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
@@ -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;
@@ -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;
+1 -1
View File
@@ -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.
+1
View File
@@ -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 {
+2
View File
@@ -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.
+1
View File
@@ -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
+27 -4
View File
@@ -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
+9
View File
@@ -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
;
+21 -1
View File
@@ -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,
}
}
+10 -17
View File
@@ -11,7 +11,6 @@ import (
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
@@ -216,29 +215,19 @@ 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
err = database.ReadModifyUpdate(store, func(tx database.Store) error {
var err error
b.store = tx
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 nil, nil, xerrors.Errorf("build tx: %w", err)
}
return workspaceBuild, provisionerJob, nil
}
return nil, nil, xerrors.Errorf("too many errors; last error: %w", err)
}
// buildTx contains the business logic of computing a new build. Attributes of the new database objects are computed
// in a functional style, rather than imperative, to emphasize the logic of how they are defined. A simple cache
@@ -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()
+2
View File
@@ -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.
+4
View File
@@ -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.
+8
View File
@@ -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 {
+2 -2
View File
@@ -9,13 +9,13 @@ We track the following resources:
<!-- Code generated by 'make docs/admin/audit-logs.md'. DO NOT EDIT -->
| <b>Resource<b> | |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr><tr><td>source</td><td>false</td></tr></tbody></table> |
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_ttl</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_ttl</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>require_active_version</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>automatic_updates</td><td>true</td></tr><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
+4
View File
@@ -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 | | |
+7
View File
@@ -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"
+1
View File
@@ -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,
+12 -2
View File
@@ -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
@@ -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
}
+46
View File
@@ -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) {
+139
View File
@@ -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())
}
})
}
})
}
+90
View File
@@ -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
+5
View File
@@ -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",
@@ -37,6 +37,7 @@ export const newTemplate = (formData: CreateTemplateData) => {
autostart_requirement: {
days_of_week: autostart_requirement_days_of_week,
},
require_active_version: false,
};
};
@@ -69,6 +69,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
template.allow_user_cancel_workspace_jobs,
update_workspace_last_used_at: false,
update_workspace_dormant_at: false,
require_active_version: false,
},
validationSchema,
onSubmit,
@@ -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 () => {
@@ -113,6 +113,7 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
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<TemplateScheduleForm> = ({
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,
});
};
@@ -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",
+1
View File
@@ -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 = {