diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden
index 91f20f1fecff7..b1e97296493c0 100644
--- a/cli/testdata/coder_list_--output_json.golden
+++ b/cli/testdata/coder_list_--output_json.golden
@@ -51,6 +51,7 @@
"autostart_schedule": "CRON_TZ=US/Central 30 9 * * 1-5",
"ttl_ms": 28800000,
"last_used_at": "[timestamp]",
- "deleting_at": null
+ "deleting_at": null,
+ "locked_at": null
}
]
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index c2e45d069c605..aef7e1b05f89d 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -5558,6 +5558,53 @@ const docTemplate = `{
}
}
},
+ "/workspaces/{workspace}/lock": {
+ "put": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Workspaces"
+ ],
+ "summary": "Update workspace lock by id.",
+ "operationId": "update-workspace-lock-by-id",
+ "parameters": [
+ {
+ "type": "string",
+ "format": "uuid",
+ "description": "Workspace ID",
+ "name": "workspace",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Lock or unlock a workspace",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.UpdateWorkspaceLock"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.Response"
+ }
+ }
+ }
+ }
+ },
"/workspaces/{workspace}/ttl": {
"put": {
"security": [
@@ -9039,6 +9086,14 @@ const docTemplate = `{
}
}
},
+ "codersdk.UpdateWorkspaceLock": {
+ "type": "object",
+ "properties": {
+ "lock": {
+ "type": "boolean"
+ }
+ }
+ },
"codersdk.UpdateWorkspaceRequest": {
"type": "object",
"properties": {
@@ -9196,6 +9251,11 @@ const docTemplate = `{
"latest_build": {
"$ref": "#/definitions/codersdk.WorkspaceBuild"
},
+ "locked_at": {
+ "description": "LockedAt being non-nil indicates a workspace that has been locked.\nA locked workspace is no longer accessible by a user and must be\nunlocked by an admin. It is subject to deletion if it breaches\nthe duration of the locked_ttl field on its template.",
+ "type": "string",
+ "format": "date-time"
+ },
"name": {
"type": "string"
},
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 4f76a783184fc..c3570a10f9c36 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -4901,6 +4901,47 @@
}
}
},
+ "/workspaces/{workspace}/lock": {
+ "put": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "consumes": ["application/json"],
+ "produces": ["application/json"],
+ "tags": ["Workspaces"],
+ "summary": "Update workspace lock by id.",
+ "operationId": "update-workspace-lock-by-id",
+ "parameters": [
+ {
+ "type": "string",
+ "format": "uuid",
+ "description": "Workspace ID",
+ "name": "workspace",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Lock or unlock a workspace",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.UpdateWorkspaceLock"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.Response"
+ }
+ }
+ }
+ }
+ },
"/workspaces/{workspace}/ttl": {
"put": {
"security": [
@@ -8151,6 +8192,14 @@
}
}
},
+ "codersdk.UpdateWorkspaceLock": {
+ "type": "object",
+ "properties": {
+ "lock": {
+ "type": "boolean"
+ }
+ }
+ },
"codersdk.UpdateWorkspaceRequest": {
"type": "object",
"properties": {
@@ -8288,6 +8337,11 @@
"latest_build": {
"$ref": "#/definitions/codersdk.WorkspaceBuild"
},
+ "locked_at": {
+ "description": "LockedAt being non-nil indicates a workspace that has been locked.\nA locked workspace is no longer accessible by a user and must be\nunlocked by an admin. It is subject to deletion if it breaches\nthe duration of the locked_ttl field on its template.",
+ "type": "string",
+ "format": "date-time"
+ },
"name": {
"type": "string"
},
diff --git a/coderd/coderd.go b/coderd/coderd.go
index 41ddcf4bbda58..32166d180fa4a 100644
--- a/coderd/coderd.go
+++ b/coderd/coderd.go
@@ -735,6 +735,7 @@ func New(options *Options) *API {
})
r.Get("/watch", api.watchWorkspace)
r.Put("/extend", api.putExtendWorkspace)
+ r.Put("/lock", api.putWorkspaceLock)
})
})
r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) {
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index fec2cc01a5edc..f5dfbdd6751ef 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -143,13 +143,14 @@ var (
DisplayName: "Provisioner Daemon",
Site: rbac.Permissions(map[string][]rbac.Action{
// TODO: Add ProvisionerJob resource type.
- rbac.ResourceFile.Type: {rbac.ActionRead},
- rbac.ResourceSystem.Type: {rbac.WildcardSymbol},
- rbac.ResourceTemplate.Type: {rbac.ActionRead, rbac.ActionUpdate},
- rbac.ResourceUser.Type: {rbac.ActionRead},
- rbac.ResourceWorkspace.Type: {rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete},
- rbac.ResourceUserData.Type: {rbac.ActionRead, rbac.ActionUpdate},
- rbac.ResourceAPIKey.Type: {rbac.WildcardSymbol},
+ rbac.ResourceFile.Type: {rbac.ActionRead},
+ rbac.ResourceSystem.Type: {rbac.WildcardSymbol},
+ rbac.ResourceTemplate.Type: {rbac.ActionRead, rbac.ActionUpdate},
+ rbac.ResourceUser.Type: {rbac.ActionRead},
+ rbac.ResourceWorkspace.Type: {rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete},
+ rbac.ResourceWorkspaceBuild.Type: {rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete},
+ rbac.ResourceUserData.Type: {rbac.ActionRead, rbac.ActionUpdate},
+ rbac.ResourceAPIKey.Type: {rbac.WildcardSymbol},
}),
Org: map[string][]rbac.Permission{},
User: []rbac.Permission{},
@@ -165,9 +166,10 @@ var (
Name: "autostart",
DisplayName: "Autostart Daemon",
Site: rbac.Permissions(map[string][]rbac.Action{
- rbac.ResourceSystem.Type: {rbac.WildcardSymbol},
- rbac.ResourceTemplate.Type: {rbac.ActionRead, rbac.ActionUpdate},
- rbac.ResourceWorkspace.Type: {rbac.ActionRead, rbac.ActionUpdate},
+ rbac.ResourceSystem.Type: {rbac.WildcardSymbol},
+ rbac.ResourceTemplate.Type: {rbac.ActionRead, rbac.ActionUpdate},
+ rbac.ResourceWorkspace.Type: {rbac.ActionRead, rbac.ActionUpdate},
+ rbac.ResourceWorkspaceBuild.Type: {rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete},
}),
Org: map[string][]rbac.Permission{},
User: []rbac.Permission{},
@@ -213,6 +215,7 @@ var (
rbac.ResourceUser.Type: {rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete},
rbac.ResourceUserData.Type: {rbac.ActionCreate, rbac.ActionUpdate},
rbac.ResourceWorkspace.Type: {rbac.ActionUpdate},
+ rbac.ResourceWorkspaceBuild.Type: {rbac.ActionUpdate},
rbac.ResourceWorkspaceExecution.Type: {rbac.ActionCreate},
rbac.ResourceWorkspaceProxy.Type: {rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete},
}),
@@ -1998,7 +2001,7 @@ func (q *querier) InsertWorkspaceBuild(ctx context.Context, arg database.InsertW
action = rbac.ActionDelete
}
- if err = q.authorizeContext(ctx, action, w); err != nil {
+ if err = q.authorizeContext(ctx, action, w.WorkspaceBuildRBAC(arg.Transition)); err != nil {
return database.WorkspaceBuild{}, err
}
@@ -2530,6 +2533,13 @@ func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.Up
return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg)
}
+func (q *querier) UpdateWorkspaceLockedAt(ctx context.Context, arg database.UpdateWorkspaceLockedAtParams) error {
+ fetch := func(ctx context.Context, arg database.UpdateWorkspaceLockedAtParams) (database.Workspace, error) {
+ return q.db.GetWorkspaceByID(ctx, arg.ID)
+ }
+ return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedAt)(ctx, arg)
+}
+
func (q *querier) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
fetch := func(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
return q.db.GetWorkspaceProxyByID(ctx, arg.ID)
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index eaa99482a812e..bde4a1dfd5ef4 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -1196,7 +1196,7 @@ func (s *MethodTestSuite) TestWorkspace() {
WorkspaceID: w.ID,
Transition: database.WorkspaceTransitionStart,
Reason: database.BuildReasonInitiator,
- }).Asserts(w, rbac.ActionUpdate)
+ }).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{})
@@ -1204,7 +1204,7 @@ func (s *MethodTestSuite) TestWorkspace() {
WorkspaceID: w.ID,
Transition: database.WorkspaceTransitionDelete,
Reason: database.BuildReasonInitiator,
- }).Asserts(w, rbac.ActionDelete)
+ }).Asserts(w.WorkspaceBuildRBAC(database.WorkspaceTransitionDelete), rbac.ActionDelete)
}))
s.Run("InsertWorkspaceBuildParameters", s.Subtest(func(db database.Store, check *expects) {
w := dbgen.Workspace(s.T(), db, database.Workspace{})
diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go
index f43e33b33772b..50195b5d77306 100644
--- a/coderd/database/dbfake/dbfake.go
+++ b/coderd/database/dbfake/dbfake.go
@@ -5197,6 +5197,26 @@ func (q *fakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database.
return sql.ErrNoRows
}
+func (q *fakeQuerier) UpdateWorkspaceLockedAt(_ context.Context, arg database.UpdateWorkspaceLockedAtParams) error {
+ if err := validateDatabaseType(arg); err != nil {
+ return err
+ }
+
+ q.mutex.Lock()
+ defer q.mutex.Unlock()
+
+ for index, workspace := range q.workspaces {
+ if workspace.ID != arg.ID {
+ continue
+ }
+ workspace.LockedAt = arg.LockedAt
+ q.workspaces[index] = workspace
+ return nil
+ }
+
+ return sql.ErrNoRows
+}
+
func (q *fakeQuerier) UpdateWorkspaceProxy(_ context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go
index bf5b4e562182e..f5dad6e7addd4 100644
--- a/coderd/database/dbmetrics/dbmetrics.go
+++ b/coderd/database/dbmetrics/dbmetrics.go
@@ -1531,6 +1531,13 @@ func (m metricsStore) UpdateWorkspaceLastUsedAt(ctx context.Context, arg databas
return err
}
+func (m metricsStore) UpdateWorkspaceLockedAt(ctx context.Context, arg database.UpdateWorkspaceLockedAtParams) error {
+ start := time.Now()
+ r0 := m.s.UpdateWorkspaceLockedAt(ctx, arg)
+ m.queryLatencies.WithLabelValues("UpdateWorkspaceLockedAt").Observe(time.Since(start).Seconds())
+ return r0
+}
+
func (m metricsStore) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
start := time.Now()
proxy, err := m.s.UpdateWorkspaceProxy(ctx, arg)
diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go
index bd39a4bc315d1..2784a66b0e872 100644
--- a/coderd/database/dbmock/dbmock.go
+++ b/coderd/database/dbmock/dbmock.go
@@ -3165,6 +3165,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceLastUsedAt(arg0, arg1 interface{
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceLastUsedAt", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceLastUsedAt), arg0, arg1)
}
+// UpdateWorkspaceLockedAt mocks base method.
+func (m *MockStore) UpdateWorkspaceLockedAt(arg0 context.Context, arg1 database.UpdateWorkspaceLockedAtParams) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateWorkspaceLockedAt", arg0, arg1)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpdateWorkspaceLockedAt indicates an expected call of UpdateWorkspaceLockedAt.
+func (mr *MockStoreMockRecorder) UpdateWorkspaceLockedAt(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceLockedAt", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceLockedAt), arg0, arg1)
+}
+
// UpdateWorkspaceProxy mocks base method.
func (m *MockStore) UpdateWorkspaceProxy(arg0 context.Context, arg1 database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
m.ctrl.T.Helper()
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index 05a5a3057a8b1..9ded04e035ddf 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -816,7 +816,8 @@ CREATE TABLE workspaces (
name character varying(64) NOT NULL,
autostart_schedule text,
ttl bigint,
- last_used_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL
+ last_used_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL,
+ locked_at timestamp with time zone
);
ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('licenses_id_seq'::regclass);
diff --git a/coderd/database/migrations/000131_workspace_locked.down.sql b/coderd/database/migrations/000131_workspace_locked.down.sql
new file mode 100644
index 0000000000000..d622787938738
--- /dev/null
+++ b/coderd/database/migrations/000131_workspace_locked.down.sql
@@ -0,0 +1,3 @@
+BEGIN;
+ALTER TABLE workspaces DROP COLUMN locked_at;
+COMMIT;
diff --git a/coderd/database/migrations/000131_workspace_locked.up.sql b/coderd/database/migrations/000131_workspace_locked.up.sql
new file mode 100644
index 0000000000000..e62a6a351d92a
--- /dev/null
+++ b/coderd/database/migrations/000131_workspace_locked.up.sql
@@ -0,0 +1,3 @@
+BEGIN;
+ALTER TABLE workspaces ADD COLUMN locked_at timestamptz NULL;
+COMMIT;
diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go
index bf3b81f3ff74c..bb7dfdd1bb818 100644
--- a/coderd/database/modelmethods.go
+++ b/coderd/database/modelmethods.go
@@ -145,6 +145,11 @@ func (w Workspace) RBACObject() rbac.Object {
}
func (w Workspace) ExecutionRBAC() rbac.Object {
+ // If a workspace is locked it cannot be accessed.
+ if w.LockedAt.Valid {
+ return w.LockedRBAC()
+ }
+
return rbac.ResourceWorkspaceExecution.
WithID(w.ID).
InOrg(w.OrganizationID).
@@ -152,12 +157,40 @@ func (w Workspace) ExecutionRBAC() rbac.Object {
}
func (w Workspace) ApplicationConnectRBAC() rbac.Object {
+ // If a workspace is locked it cannot be accessed.
+ if w.LockedAt.Valid {
+ return w.LockedRBAC()
+ }
+
return rbac.ResourceWorkspaceApplicationConnect.
WithID(w.ID).
InOrg(w.OrganizationID).
WithOwner(w.OwnerID.String())
}
+func (w Workspace) WorkspaceBuildRBAC(transition WorkspaceTransition) rbac.Object {
+ // If a workspace is locked 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.
+ if w.LockedAt.Valid && transition != WorkspaceTransitionStop &&
+ transition != WorkspaceTransitionDelete {
+ return w.LockedRBAC()
+ }
+
+ return rbac.ResourceWorkspaceBuild.
+ WithID(w.ID).
+ InOrg(w.OrganizationID).
+ WithOwner(w.OwnerID.String())
+}
+
+func (w Workspace) LockedRBAC() rbac.Object {
+ return rbac.ResourceWorkspaceLocked.
+ WithID(w.ID).
+ InOrg(w.OrganizationID).
+ WithOwner(w.OwnerID.String())
+}
+
func (m OrganizationMember) RBACObject() rbac.Object {
return rbac.ResourceOrganizationMember.
WithID(m.UserID).
diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go
index 83aeca7e06f3e..4ff6e6e2d154a 100644
--- a/coderd/database/modelqueries.go
+++ b/coderd/database/modelqueries.go
@@ -235,6 +235,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
&i.AutostartSchedule,
&i.Ttl,
&i.LastUsedAt,
+ &i.LockedAt,
&i.Count,
); err != nil {
return nil, err
diff --git a/coderd/database/models.go b/coderd/database/models.go
index d3c7f9c43682e..fb25ec7b9dc8a 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -1687,6 +1687,7 @@ type Workspace struct {
AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"`
Ttl sql.NullInt64 `db:"ttl" json:"ttl"`
LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"`
+ LockedAt sql.NullTime `db:"locked_at" json:"locked_at"`
}
type WorkspaceAgent struct {
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index afe8c742dfed4..4bf0fa648e039 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -254,6 +254,7 @@ type sqlcQuerier interface {
UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) (WorkspaceBuild, error)
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error
+ UpdateWorkspaceLockedAt(ctx context.Context, arg UpdateWorkspaceLockedAtParams) error
// This allows editing the properties of a workspace proxy.
UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error)
UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index f6b9a2bc05593..a4b1ed784f174 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -8147,7 +8147,7 @@ func (q *sqlQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploy
const getWorkspaceByAgentID = `-- name: GetWorkspaceByAgentID :one
SELECT
- id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at
+ id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at
FROM
workspaces
WHERE
@@ -8190,13 +8190,14 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI
&i.AutostartSchedule,
&i.Ttl,
&i.LastUsedAt,
+ &i.LockedAt,
)
return i, err
}
const getWorkspaceByID = `-- name: GetWorkspaceByID :one
SELECT
- id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at
+ id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at
FROM
workspaces
WHERE
@@ -8220,13 +8221,14 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp
&i.AutostartSchedule,
&i.Ttl,
&i.LastUsedAt,
+ &i.LockedAt,
)
return i, err
}
const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one
SELECT
- id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at
+ id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at
FROM
workspaces
WHERE
@@ -8257,13 +8259,14 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo
&i.AutostartSchedule,
&i.Ttl,
&i.LastUsedAt,
+ &i.LockedAt,
)
return i, err
}
const getWorkspaceByWorkspaceAppID = `-- name: GetWorkspaceByWorkspaceAppID :one
SELECT
- id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at
+ id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at
FROM
workspaces
WHERE
@@ -8313,13 +8316,14 @@ func (q *sqlQuerier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspace
&i.AutostartSchedule,
&i.Ttl,
&i.LastUsedAt,
+ &i.LockedAt,
)
return i, err
}
const getWorkspaces = `-- name: GetWorkspaces :many
SELECT
- workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, COUNT(*) OVER () as count
+ workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.locked_at, COUNT(*) OVER () as count
FROM
workspaces
JOIN
@@ -8529,6 +8533,7 @@ type GetWorkspacesRow struct {
AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"`
Ttl sql.NullInt64 `db:"ttl" json:"ttl"`
LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"`
+ LockedAt sql.NullTime `db:"locked_at" json:"locked_at"`
Count int64 `db:"count" json:"count"`
}
@@ -8565,6 +8570,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
&i.AutostartSchedule,
&i.Ttl,
&i.LastUsedAt,
+ &i.LockedAt,
&i.Count,
); err != nil {
return nil, err
@@ -8582,7 +8588,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many
SELECT
- workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at
+ workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.locked_at
FROM
workspaces
LEFT JOIN
@@ -8651,6 +8657,7 @@ func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now
&i.AutostartSchedule,
&i.Ttl,
&i.LastUsedAt,
+ &i.LockedAt,
); err != nil {
return nil, err
}
@@ -8680,7 +8687,7 @@ INSERT INTO
last_used_at
)
VALUES
- ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at
+ ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at
`
type InsertWorkspaceParams struct {
@@ -8722,6 +8729,7 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar
&i.AutostartSchedule,
&i.Ttl,
&i.LastUsedAt,
+ &i.LockedAt,
)
return i, err
}
@@ -8734,7 +8742,7 @@ SET
WHERE
id = $1
AND deleted = false
-RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at
+RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at
`
type UpdateWorkspaceParams struct {
@@ -8757,6 +8765,7 @@ func (q *sqlQuerier) UpdateWorkspace(ctx context.Context, arg UpdateWorkspacePar
&i.AutostartSchedule,
&i.Ttl,
&i.LastUsedAt,
+ &i.LockedAt,
)
return i, err
}
@@ -8818,6 +8827,25 @@ func (q *sqlQuerier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWo
return err
}
+const updateWorkspaceLockedAt = `-- name: UpdateWorkspaceLockedAt :exec
+UPDATE
+ workspaces
+SET
+ locked_at = $2
+WHERE
+ id = $1
+`
+
+type UpdateWorkspaceLockedAtParams struct {
+ ID uuid.UUID `db:"id" json:"id"`
+ LockedAt sql.NullTime `db:"locked_at" json:"locked_at"`
+}
+
+func (q *sqlQuerier) UpdateWorkspaceLockedAt(ctx context.Context, arg UpdateWorkspaceLockedAtParams) error {
+ _, err := q.db.ExecContext(ctx, updateWorkspaceLockedAt, arg.ID, arg.LockedAt)
+ return err
+}
+
const updateWorkspaceTTL = `-- name: UpdateWorkspaceTTL :exec
UPDATE
workspaces
diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql
index 0327ff5420fec..f90b66055a2f4 100644
--- a/coderd/database/queries/workspaces.sql
+++ b/coderd/database/queries/workspaces.sql
@@ -453,3 +453,11 @@ WHERE
workspace_builds.transition = 'start'::workspace_transition
)
) AND workspaces.deleted = 'false';
+
+-- name: UpdateWorkspaceLockedAt :exec
+UPDATE
+ workspaces
+SET
+ locked_at = $2
+WHERE
+ id = $1;
diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go
index 060f325c607a1..b14583351290f 100644
--- a/coderd/rbac/object.go
+++ b/coderd/rbac/object.go
@@ -28,6 +28,21 @@ var (
Type: "workspace",
}
+ // ResourceWorkspaceBuild refers to permissions necessary to
+ // insert a workspace build job.
+ // create/delete = ?
+ // read = read workspace builds
+ // update = insert/update workspace builds.
+ ResourceWorkspaceBuild = Object{
+ Type: "workspace_build",
+ }
+
+ // ResourceWorkspaceLocked is returned if a workspace is locked.
+ // It grants restricted permissions on workspace builds.
+ ResourceWorkspaceLocked = Object{
+ Type: "workspace_locked",
+ }
+
// ResourceWorkspaceProxy CRUD. Org
// create/delete = make or delete proxies
// read = read proxy urls
diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go
index d0a7bb5e68193..10506b3f719c2 100644
--- a/coderd/rbac/object_gen.go
+++ b/coderd/rbac/object_gen.go
@@ -25,7 +25,9 @@ func AllResources() []Object {
ResourceWildcard,
ResourceWorkspace,
ResourceWorkspaceApplicationConnect,
+ ResourceWorkspaceBuild,
ResourceWorkspaceExecution,
+ ResourceWorkspaceLocked,
ResourceWorkspaceProxy,
}
}
diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go
index 10e32d751ac26..ee3805b716402 100644
--- a/coderd/rbac/roles.go
+++ b/coderd/rbac/roles.go
@@ -121,7 +121,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
opts = &RoleOptions{}
}
- var ownerAndAdminExceptions []Object
+ ownerAndAdminExceptions := []Object{ResourceWorkspaceLocked}
if opts.NoOwnerWorkspaceExec {
ownerAndAdminExceptions = append(ownerAndAdminExceptions,
ResourceWorkspaceExecution,
@@ -152,7 +152,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ResourceProvisionerDaemon.Type: {ActionRead},
}),
Org: map[string][]Permission{},
- User: allPermsExcept(),
+ User: allPermsExcept(ResourceWorkspaceLocked),
}.withCachedRegoValue()
auditorRole := Role{
@@ -234,7 +234,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
Site: []Permission{},
Org: map[string][]Permission{
// Org admins should not have workspace exec perms.
- organizationID: allPermsExcept(ResourceWorkspaceExecution),
+ organizationID: allPermsExcept(ResourceWorkspaceExecution, ResourceWorkspaceLocked),
},
User: []Permission{},
}
diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go
index 6f51460f5b00d..4c8b90bdfdb67 100644
--- a/coderd/rbac/roles_test.go
+++ b/coderd/rbac/roles_test.go
@@ -318,6 +318,24 @@ func TestRolePermissions(t *testing.T) {
false: {memberMe, otherOrgAdmin, otherOrgMember, templateAdmin},
},
},
+ {
+ Name: "WorkspaceLocked",
+ Actions: rbac.AllActions(),
+ Resource: rbac.ResourceWorkspaceLocked.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID),
+ AuthorizeMap: map[bool][]authSubject{
+ true: {},
+ false: {memberMe, orgAdmin, userAdmin, otherOrgAdmin, otherOrgMember, orgMemberMe, owner, templateAdmin},
+ },
+ },
+ {
+ Name: "WorkspaceBuild",
+ Actions: rbac.AllActions(),
+ Resource: rbac.ResourceWorkspaceBuild.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID),
+ AuthorizeMap: map[bool][]authSubject{
+ true: {owner, orgAdmin, orgMemberMe},
+ false: {userAdmin, otherOrgAdmin, otherOrgMember, templateAdmin, memberMe},
+ },
+ },
}
for _, c := range testCases {
diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go
index 32956ed7941c5..b999f4a4b52c0 100644
--- a/coderd/workspacebuilds.go
+++ b/coderd/workspacebuilds.go
@@ -350,6 +350,11 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
)
var buildErr wsbuilder.BuildError
if xerrors.As(err, &buildErr) {
+ var authErr dbauthz.NotAuthorizedError
+ if xerrors.As(err, &authErr) {
+ buildErr.Status = http.StatusUnauthorized
+ }
+
if buildErr.Status == http.StatusInternalServerError {
api.Logger.Error(ctx, "workspace build error", slog.Error(buildErr.Wrapped))
}
diff --git a/coderd/workspaces.go b/coderd/workspaces.go
index 447c8403e23bf..0910a1d62f0c0 100644
--- a/coderd/workspaces.go
+++ b/coderd/workspaces.go
@@ -454,10 +454,6 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
}, nil)
var bldErr wsbuilder.BuildError
if xerrors.As(err, &bldErr) {
- if bldErr.Status == http.StatusInternalServerError {
- api.Logger.Error(ctx, "workspace build error", slog.Error(bldErr.Wrapped))
- }
-
httpapi.Write(ctx, rw, bldErr.Status, codersdk.Response{
Message: bldErr.Message,
Detail: bldErr.Error(),
@@ -755,6 +751,61 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusNoContent)
}
+// @Summary Update workspace lock by id.
+// @ID update-workspace-lock-by-id
+// @Security CoderSessionToken
+// @Accept json
+// @Produce json
+// @Tags Workspaces
+// @Param workspace path string true "Workspace ID" format(uuid)
+// @Param request body codersdk.UpdateWorkspaceLock true "Lock or unlock a workspace"
+// @Success 200 {object} codersdk.Response
+// @Router /workspaces/{workspace}/lock [put]
+func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ workspace := httpmw.WorkspaceParam(r)
+
+ var req codersdk.UpdateWorkspaceLock
+ if !httpapi.Read(ctx, rw, r, &req) {
+ return
+ }
+
+ code := http.StatusOK
+ resp := codersdk.Response{}
+
+ // If the workspace is already in the desired state do nothing!
+ if workspace.LockedAt.Valid == req.Lock {
+ httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{
+ Message: "Nothing to do!",
+ })
+ return
+ }
+
+ lockedAt := sql.NullTime{
+ Valid: req.Lock,
+ }
+ if req.Lock {
+ lockedAt.Time = database.Now()
+ }
+
+ err := api.Database.UpdateWorkspaceLockedAt(ctx, database.UpdateWorkspaceLockedAtParams{
+ ID: workspace.ID,
+ LockedAt: lockedAt,
+ })
+ if err != nil {
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Internal error updating workspace locked status.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ // TODO should we kick off a build to stop the workspace if it's started
+ // from this endpoint? I'm leaning no to keep things simple and kick
+ // the responsibility back to the client.
+ httpapi.Write(ctx, rw, code, resp)
+}
+
// @Summary Extend workspace deadline by ID
// @ID extend-workspace-deadline-by-id
// @Security CoderSessionToken
@@ -1054,10 +1105,16 @@ func convertWorkspace(
autostartSchedule = &workspace.AutostartSchedule.String
}
+ var lockedAt *time.Time
+ if workspace.LockedAt.Valid {
+ lockedAt = &workspace.LockedAt.Time
+ }
+
var (
ttlMillis = convertWorkspaceTTLMillis(workspace.Ttl)
deletingAt = calculateDeletingAt(workspace, template, workspaceBuild)
)
+
return codersdk.Workspace{
ID: workspace.ID,
CreatedAt: workspace.CreatedAt,
@@ -1077,6 +1134,7 @@ func convertWorkspace(
TTLMillis: ttlMillis,
LastUsedAt: workspace.LastUsedAt,
DeletingAt: deletingAt,
+ LockedAt: lockedAt,
}
}
diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go
index ee651dc8d626e..0df6a05808c99 100644
--- a/coderd/workspaces_test.go
+++ b/coderd/workspaces_test.go
@@ -2375,3 +2375,79 @@ func TestWorkspaceWithOptionalRichParameters(t *testing.T) {
}
require.ElementsMatch(t, expectedBuildParameters, workspaceBuildParameters)
}
+
+func TestWorkspaceLock(t *testing.T) {
+ t.Parallel()
+
+ t.Run("OK", func(t *testing.T) {
+ t.Parallel()
+ var (
+ client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
+ user = coderdtest.CreateFirstUser(t, client)
+ version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
+ _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
+ template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
+ workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
+ _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
+ )
+
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ err := client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{
+ Lock: true,
+ })
+ require.NoError(t, err)
+
+ workspace, err = client.Workspace(ctx, workspace.ID)
+ require.NoError(t, err, "fetch provisioned workspace")
+ require.NotNil(t, workspace.LockedAt)
+ require.WithinRange(t, *workspace.LockedAt, time.Now().Add(-time.Second*10), time.Now())
+
+ err = client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{
+ Lock: false,
+ })
+ require.NoError(t, err)
+
+ workspace, err = client.Workspace(ctx, workspace.ID)
+ require.NoError(t, err, "fetch provisioned workspace")
+ require.Nil(t, workspace.LockedAt)
+ })
+
+ t.Run("CannotStart", func(t *testing.T) {
+ t.Parallel()
+ var (
+ client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
+ user = coderdtest.CreateFirstUser(t, client)
+ version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
+ _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
+ template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
+ workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
+ _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
+ )
+
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ err := client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{
+ Lock: true,
+ })
+ require.NoError(t, err)
+
+ // Should be able to stop a workspace while it is locked.
+ coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
+
+ // Should not be able to start a workspace while it is locked.
+ _, err = client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
+ TemplateVersionID: template.ActiveVersionID,
+ Transition: codersdk.WorkspaceTransition(database.WorkspaceTransitionStart),
+ })
+ require.Error(t, err)
+
+ err = client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{
+ Lock: false,
+ })
+ require.NoError(t, err)
+ coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart)
+ })
+}
diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go
index 6418fded4f823..c076e9e86b6a2 100644
--- a/codersdk/workspaces.go
+++ b/codersdk/workspaces.go
@@ -38,6 +38,11 @@ type Workspace struct {
// DeletingAt indicates the time of the upcoming workspace deletion, if applicable; otherwise it is nil.
// Workspaces may have impending deletions if Template.InactivityTTL feature is turned on and the workspace is inactive.
DeletingAt *time.Time `json:"deleting_at" format:"date-time"`
+ // LockedAt being non-nil indicates a workspace that has been locked.
+ // A locked workspace is no longer accessible by a user and must be
+ // unlocked by an admin. It is subject to deletion if it breaches
+ // the duration of the locked_ttl field on its template.
+ LockedAt *time.Time `json:"locked_at" format:"date-time"`
}
type WorkspacesRequest struct {
@@ -276,6 +281,25 @@ func (c *Client) PutExtendWorkspace(ctx context.Context, id uuid.UUID, req PutEx
return nil
}
+// UpdateWorkspaceLock is a request to lock or unlock a workspace.
+type UpdateWorkspaceLock struct {
+ Lock bool `json:"lock"`
+}
+
+// UpdateWorkspaceLock locks or unlocks a workspace.
+func (c *Client) UpdateWorkspaceLock(ctx context.Context, id uuid.UUID, req UpdateWorkspaceLock) error {
+ path := fmt.Sprintf("/api/v2/workspaces/%s/lock", id.String())
+ res, err := c.Request(ctx, http.MethodPut, path, req)
+ if err != nil {
+ return xerrors.Errorf("update workspace lock: %w", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNotModified {
+ return ReadBodyAsError(res)
+ }
+ return nil
+}
+
type WorkspaceFilter struct {
// Owner can be "me" or a username
Owner string `json:"owner,omitempty" typescript:"-"`
diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md
index d0aa82a32fd85..7f86412268658 100644
--- a/docs/admin/audit-logs.md
+++ b/docs/admin/audit-logs.md
@@ -18,7 +18,7 @@ We track the following resources:
| Template
write, delete |
Field | Tracked |
---|
active_version_id | true |
allow_user_autostart | true |
allow_user_autostop | true |
allow_user_cancel_workspace_jobs | true |
created_at | false |
created_by | true |
default_ttl | true |
deleted | false |
description | true |
display_name | true |
failure_ttl | true |
group_acl | true |
icon | true |
id | true |
inactivity_ttl | true |
locked_ttl | true |
max_ttl | true |
name | true |
organization_id | false |
provisioner | true |
updated_at | false |
user_acl | true |
|
| TemplateVersion
create, write | Field | Tracked |
---|
created_at | false |
created_by | true |
git_auth_providers | false |
id | true |
job_id | false |
name | true |
organization_id | false |
readme | true |
template_id | true |
updated_at | false |
|
| User
create, write, delete | Field | Tracked |
---|
avatar_url | false |
created_at | false |
deleted | true |
email | true |
hashed_password | true |
id | true |
last_seen_at | false |
login_type | false |
rbac_roles | true |
status | true |
updated_at | false |
username | true |
|
-| Workspace
create, write, delete | Field | Tracked |
---|
autostart_schedule | true |
created_at | false |
deleted | false |
id | true |
last_used_at | false |
name | true |
organization_id | false |
owner_id | true |
template_id | true |
ttl | true |
updated_at | false |
|
+| Workspace
create, write, delete | Field | Tracked |
---|
autostart_schedule | true |
created_at | false |
deleted | false |
id | true |
last_used_at | false |
locked_at | true |
name | true |
organization_id | false |
owner_id | true |
template_id | true |
ttl | true |
updated_at | false |
|
| WorkspaceBuild
start, stop | Field | Tracked |
---|
build_number | false |
created_at | false |
daily_cost | false |
deadline | false |
id | false |
initiator_id | false |
job_id | false |
max_deadline | false |
provisioner_state | false |
reason | false |
template_version_id | true |
transition | false |
updated_at | false |
workspace_id | false |
|
| WorkspaceProxy
| Field | Tracked |
---|
created_at | true |
deleted | false |
display_name | true |
icon | true |
id | true |
name | true |
token_hashed_secret | true |
updated_at | false |
url | true |
wildcard_hostname | true |
|
diff --git a/docs/api/schemas.md b/docs/api/schemas.md
index 8dcac8e58af4f..08b77eba69a11 100644
--- a/docs/api/schemas.md
+++ b/docs/api/schemas.md
@@ -4299,6 +4299,20 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| ---------- | ------ | -------- | ------------ | ----------- |
| `schedule` | string | false | | |
+## codersdk.UpdateWorkspaceLock
+
+```json
+{
+ "lock": true
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+| ------ | ------- | -------- | ------------ | ----------- |
+| `lock` | boolean | false | | |
+
## codersdk.UpdateWorkspaceRequest
```json
@@ -4578,6 +4592,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string"
},
+ "locked_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"outdated": true,
@@ -4595,26 +4610,27 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
### Properties
-| Name | Type | Required | Restrictions | Description |
-| ------------------------------------------- | -------------------------------------------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `autostart_schedule` | string | false | | |
-| `created_at` | string | false | | |
-| `deleting_at` | string | false | | Deleting at indicates the time of the upcoming workspace deletion, if applicable; otherwise it is nil. Workspaces may have impending deletions if Template.InactivityTTL feature is turned on and the workspace is inactive. |
-| `id` | string | false | | |
-| `last_used_at` | string | false | | |
-| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | |
-| `name` | string | false | | |
-| `organization_id` | string | false | | |
-| `outdated` | boolean | false | | |
-| `owner_id` | string | false | | |
-| `owner_name` | string | false | | |
-| `template_allow_user_cancel_workspace_jobs` | boolean | false | | |
-| `template_display_name` | string | false | | |
-| `template_icon` | string | false | | |
-| `template_id` | string | false | | |
-| `template_name` | string | false | | |
-| `ttl_ms` | integer | false | | |
-| `updated_at` | string | false | | |
+| Name | Type | Required | Restrictions | Description |
+| ------------------------------------------- | -------------------------------------------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `autostart_schedule` | string | false | | |
+| `created_at` | string | false | | |
+| `deleting_at` | string | false | | Deleting at indicates the time of the upcoming workspace deletion, if applicable; otherwise it is nil. Workspaces may have impending deletions if Template.InactivityTTL feature is turned on and the workspace is inactive. |
+| `id` | string | false | | |
+| `last_used_at` | string | false | | |
+| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | |
+| `locked_at` | string | false | | Locked at being non-nil indicates a workspace that has been locked. A locked workspace is no longer accessible by a user and must be unlocked by an admin. It is subject to deletion if it breaches the duration of the locked_ttl field on its template. |
+| `name` | string | false | | |
+| `organization_id` | string | false | | |
+| `outdated` | boolean | false | | |
+| `owner_id` | string | false | | |
+| `owner_name` | string | false | | |
+| `template_allow_user_cancel_workspace_jobs` | boolean | false | | |
+| `template_display_name` | string | false | | |
+| `template_icon` | string | false | | |
+| `template_id` | string | false | | |
+| `template_name` | string | false | | |
+| `ttl_ms` | integer | false | | |
+| `updated_at` | string | false | | |
## codersdk.WorkspaceAgent
@@ -5619,6 +5635,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string"
},
+ "locked_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"outdated": true,
diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md
index 59517f6f35a7a..4a9b6b79be138 100644
--- a/docs/api/workspaces.md
+++ b/docs/api/workspaces.md
@@ -174,6 +174,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string"
},
+ "locked_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"outdated": true,
@@ -353,6 +354,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string"
},
+ "locked_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"outdated": true,
@@ -552,6 +554,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string"
},
+ "locked_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"outdated": true,
@@ -732,6 +735,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string"
},
+ "locked_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"outdated": true,
@@ -881,6 +885,60 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/extend \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+## Update workspace lock by id.
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/lock \
+ -H 'Content-Type: application/json' \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`PUT /workspaces/{workspace}/lock`
+
+> Body parameter
+
+```json
+{
+ "lock": true
+}
+```
+
+### Parameters
+
+| Name | In | Type | Required | Description |
+| ----------- | ---- | ---------------------------------------------------------------------- | -------- | -------------------------- |
+| `workspace` | path | string(uuid) | true | Workspace ID |
+| `body` | body | [codersdk.UpdateWorkspaceLock](schemas.md#codersdkupdateworkspacelock) | true | Lock or unlock a workspace |
+
+### Example responses
+
+> 200 Response
+
+```json
+{
+ "detail": "string",
+ "message": "string",
+ "validations": [
+ {
+ "detail": "string",
+ "field": "string"
+ }
+ ]
+}
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------ |
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
## Update workspace TTL by ID
### Code samples
diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go
index ed1eabf115247..2613979533ef2 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -117,6 +117,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
"autostart_schedule": ActionTrack,
"ttl": ActionTrack,
"last_used_at": ActionIgnore,
+ "locked_at": ActionTrack,
},
&database.WorkspaceBuild{}: {
"id": ActionIgnore,
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 49bdcdbb326ff..b026033973e1b 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -998,6 +998,11 @@ export interface UpdateWorkspaceAutostartRequest {
readonly schedule?: string
}
+// From codersdk/workspaces.go
+export interface UpdateWorkspaceLock {
+ readonly lock: boolean
+}
+
// From codersdk/workspaceproxy.go
export interface UpdateWorkspaceProxyResponse {
readonly proxy: WorkspaceProxy
@@ -1075,6 +1080,7 @@ export interface Workspace {
readonly ttl_ms?: number
readonly last_used_at: string
readonly deleting_at?: string
+ readonly locked_at?: string
}
// From codersdk/workspaceagents.go