Skip to content

Commit 9126567

Browse files
authored
chore: add auditing to workspace dormancy (#10070)
- 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 9126567

File tree

8 files changed

+236
-62
lines changed

8 files changed

+236
-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: 63 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,24 @@ 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+
auditBuild(e.ctx, e.log, *e.auditor.Load(), auditParams{
205+
Build: build,
206+
Job: latestJob,
207+
Reason: reason,
208+
Old: wsOld,
209+
New: ws,
210+
Success: err == nil,
211+
})
212+
195213
if err != nil {
196214
log.Error(e.ctx, "unable to transition workspace to dormant",
197215
slog.F("transition", nextTransition),
@@ -384,3 +402,46 @@ func isEligibleForFailedStop(build database.WorkspaceBuild, job database.Provisi
384402
job.CompletedAt.Valid &&
385403
currentTick.Sub(job.CompletedAt.Time) > templateSchedule.FailureTTL
386404
}
405+
406+
type auditParams struct {
407+
Build *database.WorkspaceBuild
408+
Job database.ProvisionerJob
409+
Reason database.BuildReason
410+
Old database.Workspace
411+
New database.Workspace
412+
Success bool
413+
}
414+
415+
func auditBuild(ctx context.Context, log slog.Logger, auditor audit.Auditor, params auditParams) {
416+
fields := audit.AdditionalFields{
417+
WorkspaceName: params.New.Name,
418+
BuildReason: params.Reason,
419+
}
420+
421+
if params.Build != nil {
422+
fields.BuildNumber = strconv.FormatInt(int64(params.Build.BuildNumber), 10)
423+
}
424+
425+
raw, err := json.Marshal(fields)
426+
if err != nil {
427+
log.Error(ctx, "marshal resource info for successful job", slog.Error(err))
428+
}
429+
430+
status := http.StatusInternalServerError
431+
if params.Success {
432+
status = http.StatusOK
433+
}
434+
435+
audit.WorkspaceBuildAudit(ctx, &audit.BuildAuditParams[database.Workspace]{
436+
Audit: auditor,
437+
Log: log,
438+
UserID: params.Job.InitiatorID,
439+
OrganizationID: params.New.OrganizationID,
440+
JobID: params.Job.ID,
441+
Action: database.AuditActionWrite,
442+
Old: params.Old,
443+
New: params.New,
444+
Status: status,
445+
AdditionalFields: raw,
446+
})
447+
}

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: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2824,7 +2824,11 @@ 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{
2829+
IncludeProvisionerDaemon: true,
2830+
Auditor: auditRecorder,
2831+
})
28282832
user = coderdtest.CreateFirstUser(t, client)
28292833
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
28302834
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
@@ -2841,10 +2845,12 @@ func TestWorkspaceDormant(t *testing.T) {
28412845
defer cancel()
28422846

28432847
lastUsedAt := workspace.LastUsedAt
2848+
auditRecorder.ResetLogs()
28442849
err := client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{
28452850
Dormant: true,
28462851
})
28472852
require.NoError(t, err)
2853+
require.Len(t, auditRecorder.AuditLogs(), 1)
28482854

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

0 commit comments

Comments
 (0)