Skip to content

Commit 148219f

Browse files
committed
chore: add auditing to workspace dormancy
- Adds an audit log for workspaces automatically transitioned to the dormant state. - Imposes a mininum of 1 minute on cleanup-related fields. This is to prevent accidental API misuse from resulting in catastrophe.
1 parent 888b97f commit 148219f

File tree

9 files changed

+212
-62
lines changed

9 files changed

+212
-62
lines changed

cli/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -938,7 +938,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
938938
autobuildTicker := time.NewTicker(vals.AutobuildPollInterval.Value())
939939
defer autobuildTicker.Stop()
940940
autobuildExecutor := autobuild.NewExecutor(
941-
ctx, options.Database, options.Pubsub, coderAPI.TemplateScheduleStore, logger, autobuildTicker.C)
941+
ctx, options.Database, options.Pubsub, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, logger, autobuildTicker.C)
942942
autobuildExecutor.Run()
943943

944944
hangDetectorTicker := time.NewTicker(vals.JobHangDetectorInterval.Value())

coderd/autobuild/lifecycle_executor.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package autobuild
33
import (
44
"context"
55
"database/sql"
6+
"encoding/json"
7+
"net/http"
8+
"strconv"
69
"sync"
710
"sync/atomic"
811
"time"
@@ -12,6 +15,7 @@ import (
1215
"golang.org/x/xerrors"
1316

1417
"cdr.dev/slog"
18+
"github.com/coder/coder/v2/coderd/audit"
1519
"github.com/coder/coder/v2/coderd/database"
1620
"github.com/coder/coder/v2/coderd/database/dbauthz"
1721
"github.com/coder/coder/v2/coderd/database/dbtime"
@@ -29,6 +33,7 @@ type Executor struct {
2933
db database.Store
3034
ps pubsub.Pubsub
3135
templateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
36+
auditor *atomic.Pointer[audit.Auditor]
3237
log slog.Logger
3338
tick <-chan time.Time
3439
statsCh chan<- Stats
@@ -42,7 +47,7 @@ type Stats struct {
4247
}
4348

4449
// New returns a new wsactions executor.
45-
func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, tss *atomic.Pointer[schedule.TemplateScheduleStore], log slog.Logger, tick <-chan time.Time) *Executor {
50+
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 {
4651
le := &Executor{
4752
//nolint:gocritic // Autostart has a limited set of permissions.
4853
ctx: dbauthz.AsAutostart(ctx),
@@ -51,6 +56,7 @@ func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, tss *
5156
templateScheduleStore: tss,
5257
tick: tick,
5358
log: log.Named("autobuild"),
59+
auditor: auditor,
5460
}
5561
return le
5662
}
@@ -166,13 +172,14 @@ func (e *Executor) runOnce(t time.Time) Stats {
166172
return nil
167173
}
168174

175+
var build *database.WorkspaceBuild
169176
if nextTransition != "" {
170177
builder := wsbuilder.New(ws, nextTransition).
171178
SetLastWorkspaceBuildInTx(&latestBuild).
172179
SetLastWorkspaceBuildJobInTx(&latestJob).
173180
Reason(reason)
174181

175-
_, job, err = builder.Build(e.ctx, tx, nil)
182+
build, job, err = builder.Build(e.ctx, tx, nil)
176183
if err != nil {
177184
log.Error(e.ctx, "unable to transition workspace",
178185
slog.F("transition", nextTransition),
@@ -185,13 +192,41 @@ func (e *Executor) runOnce(t time.Time) Stats {
185192
// Transition the workspace to dormant if it has breached the template's
186193
// threshold for inactivity.
187194
if reason == database.BuildReasonAutolock {
195+
wsOld := ws
188196
ws, err = tx.UpdateWorkspaceDormantDeletingAt(e.ctx, database.UpdateWorkspaceDormantDeletingAtParams{
189197
ID: ws.ID,
190198
DormantAt: sql.NullTime{
191199
Time: dbtime.Now(),
192200
Valid: true,
193201
},
194202
})
203+
204+
fields := audit.AdditionalFields{
205+
WorkspaceName: ws.Name,
206+
BuildReason: reason,
207+
}
208+
if build != nil {
209+
fields.BuildNumber = strconv.FormatInt(int64(latestBuild.BuildNumber), 10)
210+
}
211+
212+
raw, err := json.Marshal(fields)
213+
if err != nil {
214+
e.log.Error(e.ctx, "marshal resource info for successful job", slog.Error(err))
215+
}
216+
217+
audit.WorkspaceBuildAudit(e.ctx, &audit.BuildAuditParams[database.Workspace]{
218+
Audit: *e.auditor.Load(),
219+
Log: e.log,
220+
UserID: job.InitiatorID,
221+
OrganizationID: ws.OrganizationID,
222+
JobID: job.ID,
223+
Action: database.AuditActionWrite,
224+
Old: wsOld,
225+
New: ws,
226+
Status: http.StatusOK,
227+
AdditionalFields: raw,
228+
})
229+
195230
if err != nil {
196231
log.Error(e.ctx, "unable to transition workspace to dormant",
197232
slog.F("transition", nextTransition),

coderd/coderdtest/coderdtest.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,12 +262,19 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
262262
}
263263
templateScheduleStore.Store(&options.TemplateScheduleStore)
264264

265+
var auditor atomic.Pointer[audit.Auditor]
266+
if options.Auditor == nil {
267+
options.Auditor = audit.NewNop()
268+
}
269+
auditor.Store(&options.Auditor)
270+
265271
ctx, cancelFunc := context.WithCancel(context.Background())
266272
lifecycleExecutor := autobuild.NewExecutor(
267273
ctx,
268274
options.Database,
269275
options.Pubsub,
270276
&templateScheduleStore,
277+
&auditor,
271278
slogtest.Make(t, nil).Named("autobuild.executor").Leveled(slog.LevelDebug),
272279
options.AutobuildTicker,
273280
).WithStatsChannel(options.AutobuildStats)

coderd/templates.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -537,17 +537,19 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
537537
if req.AutostopRequirement.Weeks > schedule.MaxTemplateAutostopRequirementWeeks {
538538
validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateAutostopRequirementWeeks)})
539539
}
540-
if req.FailureTTLMillis < 0 {
541-
validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Must be a positive integer."})
542-
}
543-
if req.TimeTilDormantMillis < 0 {
544-
validErrs = append(validErrs, codersdk.ValidationError{Field: "inactivity_ttl_ms", Detail: "Must be a positive integer."})
540+
541+
// The minimum valid value for a dormant TTL is 1 minute. This is
542+
// to ensure an uninformed user does not send an unintentionally
543+
// small number resulting in potentially catastrophic consequences.
544+
const minTTL = 1000 * 60
545+
if req.FailureTTLMillis < 0 || (req.FailureTTLMillis > 0 && req.FailureTTLMillis < minTTL) {
546+
validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Value must be at least one minute."})
545547
}
546-
if req.TimeTilDormantMillis < 0 {
547-
validErrs = append(validErrs, codersdk.ValidationError{Field: "inactivity_ttl_ms", Detail: "Must be a positive integer."})
548+
if req.TimeTilDormantMillis < 0 || (req.TimeTilDormantMillis > 0 && req.TimeTilDormantMillis < minTTL) {
549+
validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_dormant_ms", Detail: "Value must be at least one minute."})
548550
}
549-
if req.TimeTilDormantAutoDeleteMillis < 0 {
550-
validErrs = append(validErrs, codersdk.ValidationError{Field: "locked_ttl_ms", Detail: "Must be a positive integer."})
551+
if req.TimeTilDormantAutoDeleteMillis < 0 || (req.TimeTilDormantAutoDeleteMillis > 0 && req.TimeTilDormantAutoDeleteMillis < minTTL) {
552+
validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_dormant_autodelete_ms", Detail: "Value must be at least one minute."})
551553
}
552554

553555
if len(validErrs) > 0 {

coderd/workspaces.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -816,8 +816,20 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
816816
// @Success 200 {object} codersdk.Workspace
817817
// @Router /workspaces/{workspace}/dormant [put]
818818
func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) {
819-
ctx := r.Context()
820-
workspace := httpmw.WorkspaceParam(r)
819+
var (
820+
ctx = r.Context()
821+
workspace = httpmw.WorkspaceParam(r)
822+
oldWorkspace = workspace
823+
auditor = api.Auditor.Load()
824+
aReq, commitAudit = audit.InitRequest[database.Workspace](rw, &audit.RequestParams{
825+
Audit: *auditor,
826+
Log: api.Logger,
827+
Request: r,
828+
Action: database.AuditActionWrite,
829+
})
830+
)
831+
aReq.Old = oldWorkspace
832+
defer commitAudit()
821833

822834
var req codersdk.UpdateWorkspaceDormancy
823835
if !httpapi.Read(ctx, rw, r, &req) {
@@ -865,6 +877,7 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) {
865877
return
866878
}
867879

880+
aReq.New = workspace
868881
httpapi.Write(ctx, rw, http.StatusOK, convertWorkspace(
869882
workspace,
870883
data.builds[0],

coderd/workspaces_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2824,7 +2824,10 @@ func TestWorkspaceDormant(t *testing.T) {
28242824
t.Run("OK", func(t *testing.T) {
28252825
t.Parallel()
28262826
var (
2827-
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
2827+
auditRecorder = audit.NewMock()
2828+
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true,
2829+
Auditor: auditRecorder,
2830+
})
28282831
user = coderdtest.CreateFirstUser(t, client)
28292832
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
28302833
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
@@ -2841,10 +2844,12 @@ func TestWorkspaceDormant(t *testing.T) {
28412844
defer cancel()
28422845

28432846
lastUsedAt := workspace.LastUsedAt
2847+
auditRecorder.ResetLogs()
28442848
err := client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{
28452849
Dormant: true,
28462850
})
28472851
require.NoError(t, err)
2852+
require.Len(t, auditRecorder.AuditLogs(), 1)
28482853

28492854
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
28502855
require.NoError(t, err, "fetch provisioned workspace")

codersdk/audit.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const (
7272
AuditActionLogin AuditAction = "login"
7373
AuditActionLogout AuditAction = "logout"
7474
AuditActionRegister AuditAction = "register"
75+
AuditActionDormant AuditAction = "dormant"
7576
)
7677

7778
func (a AuditAction) Friendly() string {
@@ -92,6 +93,8 @@ func (a AuditAction) Friendly() string {
9293
return "logged out"
9394
case AuditActionRegister:
9495
return "registered"
96+
case AuditActionDormant:
97+
return "has gone dormant"
9598
default:
9699
return "unknown"
97100
}

enterprise/coderd/templates_test.go

Lines changed: 110 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -185,55 +185,123 @@ func TestTemplates(t *testing.T) {
185185
})
186186

187187
t.Run("CleanupTTLs", func(t *testing.T) {
188-
t.Parallel()
188+
t.Run("OK", func(t *testing.T) {
189+
t.Parallel()
189190

190-
ctx := testutil.Context(t, testutil.WaitMedium)
191-
client, user := coderdenttest.New(t, &coderdenttest.Options{
192-
Options: &coderdtest.Options{
193-
IncludeProvisionerDaemon: true,
194-
},
195-
LicenseOptions: &coderdenttest.LicenseOptions{
196-
Features: license.Features{
197-
codersdk.FeatureAdvancedTemplateScheduling: 1,
191+
ctx := testutil.Context(t, testutil.WaitMedium)
192+
client, user := coderdenttest.New(t, &coderdenttest.Options{
193+
Options: &coderdtest.Options{
194+
IncludeProvisionerDaemon: true,
198195
},
199-
},
196+
LicenseOptions: &coderdenttest.LicenseOptions{
197+
Features: license.Features{
198+
codersdk.FeatureAdvancedTemplateScheduling: 1,
199+
},
200+
},
201+
})
202+
203+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
204+
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
205+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
206+
require.EqualValues(t, 0, template.TimeTilDormantMillis)
207+
require.EqualValues(t, 0, template.FailureTTLMillis)
208+
require.EqualValues(t, 0, template.TimeTilDormantAutoDeleteMillis)
209+
210+
var (
211+
failureTTL time.Duration = 1 * time.Minute
212+
inactivityTTL time.Duration = 2 * time.Minute
213+
dormantTTL time.Duration = 3 * time.Minute
214+
)
215+
216+
updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
217+
Name: template.Name,
218+
DisplayName: template.DisplayName,
219+
Description: template.Description,
220+
Icon: template.Icon,
221+
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
222+
TimeTilDormantMillis: inactivityTTL.Milliseconds(),
223+
FailureTTLMillis: failureTTL.Milliseconds(),
224+
TimeTilDormantAutoDeleteMillis: dormantTTL.Milliseconds(),
225+
})
226+
require.NoError(t, err)
227+
require.Equal(t, failureTTL.Milliseconds(), updated.FailureTTLMillis)
228+
require.Equal(t, inactivityTTL.Milliseconds(), updated.TimeTilDormantMillis)
229+
require.Equal(t, dormantTTL.Milliseconds(), updated.TimeTilDormantAutoDeleteMillis)
230+
231+
// Validate fetching the template returns the same values as updating
232+
// the template.
233+
template, err = client.Template(ctx, template.ID)
234+
require.NoError(t, err)
235+
require.Equal(t, failureTTL.Milliseconds(), updated.FailureTTLMillis)
236+
require.Equal(t, inactivityTTL.Milliseconds(), updated.TimeTilDormantMillis)
237+
require.Equal(t, dormantTTL.Milliseconds(), updated.TimeTilDormantAutoDeleteMillis)
200238
})
201239

202-
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
203-
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
204-
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
205-
require.EqualValues(t, 0, template.TimeTilDormantMillis)
206-
require.EqualValues(t, 0, template.FailureTTLMillis)
207-
require.EqualValues(t, 0, template.TimeTilDormantAutoDeleteMillis)
240+
t.Run("BadRequest", func(t *testing.T) {
241+
t.Parallel()
208242

209-
var (
210-
failureTTL int64 = 1
211-
inactivityTTL int64 = 2
212-
dormantTTL int64 = 3
213-
)
243+
ctx := testutil.Context(t, testutil.WaitMedium)
244+
client, user := coderdenttest.New(t, &coderdenttest.Options{
245+
Options: &coderdtest.Options{
246+
IncludeProvisionerDaemon: true,
247+
},
248+
LicenseOptions: &coderdenttest.LicenseOptions{
249+
Features: license.Features{
250+
codersdk.FeatureAdvancedTemplateScheduling: 1,
251+
},
252+
},
253+
})
214254

215-
updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
216-
Name: template.Name,
217-
DisplayName: template.DisplayName,
218-
Description: template.Description,
219-
Icon: template.Icon,
220-
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
221-
TimeTilDormantMillis: inactivityTTL,
222-
FailureTTLMillis: failureTTL,
223-
TimeTilDormantAutoDeleteMillis: dormantTTL,
224-
})
225-
require.NoError(t, err)
226-
require.Equal(t, failureTTL, updated.FailureTTLMillis)
227-
require.Equal(t, inactivityTTL, updated.TimeTilDormantMillis)
228-
require.Equal(t, dormantTTL, updated.TimeTilDormantAutoDeleteMillis)
255+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
256+
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
257+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
229258

230-
// Validate fetching the template returns the same values as updating
231-
// the template.
232-
template, err = client.Template(ctx, template.ID)
233-
require.NoError(t, err)
234-
require.Equal(t, failureTTL, updated.FailureTTLMillis)
235-
require.Equal(t, inactivityTTL, updated.TimeTilDormantMillis)
236-
require.Equal(t, dormantTTL, updated.TimeTilDormantAutoDeleteMillis)
259+
type testcase struct {
260+
Name string
261+
TimeTilDormantMS int64
262+
FailureTTLMS int64
263+
DormantAutoDeleteMS int64
264+
}
265+
266+
cases := []testcase{
267+
{
268+
Name: "NegativeValue",
269+
TimeTilDormantMS: -1,
270+
FailureTTLMS: -2,
271+
DormantAutoDeleteMS: -3,
272+
},
273+
{
274+
Name: "ValueTooSmall",
275+
TimeTilDormantMS: 1,
276+
FailureTTLMS: 999,
277+
DormantAutoDeleteMS: 500,
278+
},
279+
}
280+
281+
for _, c := range cases {
282+
c := c
283+
284+
t.Run(c.Name, func(t *testing.T) {
285+
t.Parallel()
286+
287+
_, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
288+
Name: template.Name,
289+
DisplayName: template.DisplayName,
290+
Description: template.Description,
291+
Icon: template.Icon,
292+
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
293+
TimeTilDormantMillis: c.TimeTilDormantMS,
294+
FailureTTLMillis: c.FailureTTLMS,
295+
TimeTilDormantAutoDeleteMillis: c.DormantAutoDeleteMS,
296+
})
297+
require.Error(t, err)
298+
cerr, ok := codersdk.AsError(err)
299+
require.True(t, ok)
300+
require.Len(t, cerr.Validations, 3)
301+
require.Equal(t, "Value must be at least one minute.", cerr.Validations[0].Detail)
302+
})
303+
}
304+
})
237305
})
238306

239307
t.Run("UpdateTimeTilDormantAutoDelete", func(t *testing.T) {

0 commit comments

Comments
 (0)