feat: provide endpoint to lock/unlock workspace (#8239)

This commit is contained in:
Jon Ayers
2023-06-28 16:12:49 -05:00
committed by GitHub
parent 72e83df578
commit 749307ef08
31 changed files with 577 additions and 51 deletions

60
coderd/apidoc/docs.go generated
View File

@ -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"
},

View File

@ -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"
},

View File

@ -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) {

View File

@ -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)

View File

@ -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{})

View File

@ -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()

View File

@ -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)

View File

@ -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()

View File

@ -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);

View File

@ -0,0 +1,3 @@
BEGIN;
ALTER TABLE workspaces DROP COLUMN locked_at;
COMMIT;

View File

@ -0,0 +1,3 @@
BEGIN;
ALTER TABLE workspaces ADD COLUMN locked_at timestamptz NULL;
COMMIT;

View File

@ -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).

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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

View File

@ -25,7 +25,9 @@ func AllResources() []Object {
ResourceWildcard,
ResourceWorkspace,
ResourceWorkspaceApplicationConnect,
ResourceWorkspaceBuild,
ResourceWorkspaceExecution,
ResourceWorkspaceLocked,
ResourceWorkspaceProxy,
}
}

View File

@ -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{},
}

View File

@ -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 {

View File

@ -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))
}

View File

@ -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,
}
}

View File

@ -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)
})
}