From 2a650f077c5bdf7616888e249b2e8a9ab90b2434 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 22 Jun 2023 20:41:45 +0000 Subject: [PATCH 1/2] feat: provide endpoint to lock/unlock workspace --- cli/testdata/coder_list_--output_json.golden | 3 +- coderd/apidoc/docs.go | 60 +++++++++++++++ coderd/apidoc/swagger.json | 54 +++++++++++++ coderd/coderd.go | 1 + coderd/database/dbauthz/dbauthz.go | 32 +++++--- coderd/database/dbauthz/dbauthz_test.go | 4 +- coderd/database/dbfake/dbfake.go | 20 +++++ coderd/database/dbmetrics/dbmetrics.go | 7 ++ coderd/database/dbmock/dbmock.go | 14 ++++ coderd/database/dump.sql | 3 +- .../000131_workspace_locked.down.sql | 3 + .../migrations/000131_workspace_locked.up.sql | 3 + coderd/database/modelmethods.go | 33 ++++++++ coderd/database/modelqueries.go | 1 + coderd/database/models.go | 1 + coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 44 +++++++++-- coderd/database/queries/workspaces.sql | 8 ++ coderd/rbac/object.go | 12 +++ coderd/rbac/object_gen.go | 2 + coderd/rbac/roles.go | 6 +- coderd/rbac/roles_test.go | 18 +++++ coderd/workspacebuilds.go | 5 ++ coderd/workspaces.go | 66 +++++++++++++++- coderd/workspaces_test.go | 76 +++++++++++++++++++ codersdk/workspaces.go | 24 ++++++ docs/admin/audit-logs.md | 2 +- docs/api/schemas.md | 57 +++++++++----- docs/api/workspaces.md | 58 ++++++++++++++ enterprise/audit/table.go | 1 + site/src/api/typesGenerated.ts | 6 ++ 31 files changed, 574 insertions(+), 51 deletions(-) create mode 100644 coderd/database/migrations/000131_workspace_locked.down.sql create mode 100644 coderd/database/migrations/000131_workspace_locked.up.sql 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..c8d6932143250 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -28,6 +28,18 @@ var ( Type: "workspace", } + ResourceWorkspaceBuild = Object{ + Type: "workspace_build", + } + + // ResourceWorkspaceLocked CRUD. + // create/delete = make or delete workspaces + // read = access workspace + // update = edit workspace variables + 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 |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| | TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| | User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
locked_attrue
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| | WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
display_nametrue
icontrue
idtrue
nametrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| 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 From 69947aca541cfd361991561173a19742e1b577cb Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 28 Jun 2023 20:56:18 +0000 Subject: [PATCH 2/2] update comments --- coderd/rbac/object.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index c8d6932143250..b14583351290f 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -28,14 +28,17 @@ 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 CRUD. - // create/delete = make or delete workspaces - // read = access workspace - // update = edit workspace variables + // ResourceWorkspaceLocked is returned if a workspace is locked. + // It grants restricted permissions on workspace builds. ResourceWorkspaceLocked = Object{ Type: "workspace_locked", }