Skip to content

Commit edecb9f

Browse files
committed
fix: restrict edit schedule access
1 parent 482feef commit edecb9f

File tree

5 files changed

+119
-17
lines changed

5 files changed

+119
-17
lines changed

site/src/components/Workspace/Workspace.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export const Workspace: FC<WorkspaceProps> = ({
108108
workspace={workspace}
109109
onDeadlineMinus={scheduleProps.onDeadlineMinus}
110110
onDeadlinePlus={scheduleProps.onDeadlinePlus}
111+
canUpdateWorkspace={canUpdateWorkspace}
111112
/>
112113
</Stack>
113114
</Stack>

site/src/components/WorkspaceSchedule/WorkspaceSchedule.stories.tsx

+20-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ const THIRTY = 30
1616
export default {
1717
title: "components/WorkspaceSchedule",
1818
component: WorkspaceSchedule,
19+
argTypes: {
20+
canUpdateWorkspace: {
21+
defaultValue: true,
22+
},
23+
},
1924
}
2025

2126
const Template: Story<WorkspaceScheduleProps> = (args) => <WorkspaceSchedule {...args} />
@@ -40,7 +45,7 @@ NoTTL.args = {
4045
...Mocks.MockWorkspace,
4146
latest_build: {
4247
...Mocks.MockWorkspaceBuild,
43-
// a mannual shutdown has a deadline of '"0001-01-01T00:00:00Z"'
48+
// a manual shutdown has a deadline of '"0001-01-01T00:00:00Z"'
4449
// SEE: #1834
4550
deadline: "0001-01-01T00:00:00Z",
4651
},
@@ -113,3 +118,17 @@ WorkspaceOffLong.args = {
113118
ttl_ms: 2 * 365 * 24 * 60 * 60 * 1000, // 2 years
114119
},
115120
}
121+
122+
export const CannotEdit = Template.bind({})
123+
CannotEdit.args = {
124+
workspace: {
125+
...Mocks.MockWorkspace,
126+
127+
latest_build: {
128+
...Mocks.MockWorkspaceBuild,
129+
transition: "stop",
130+
},
131+
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours
132+
},
133+
canUpdateWorkspace: false,
134+
}

site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx

+13-9
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export interface WorkspaceScheduleProps {
8585
workspace: Workspace
8686
onDeadlinePlus: () => void
8787
onDeadlineMinus: () => void
88+
canUpdateWorkspace: boolean
8889
}
8990

9091
export const shouldDisplayPlusMinus = (workspace: Workspace): boolean => {
@@ -110,6 +111,7 @@ export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({
110111
workspace,
111112
onDeadlineMinus,
112113
onDeadlinePlus,
114+
canUpdateWorkspace,
113115
}) => {
114116
const styles = useStyles()
115117
const editDeadlineButtons = shouldDisplayPlusMinus(workspace) ? (
@@ -159,15 +161,17 @@ export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({
159161
{editDeadlineButtons}
160162
</Stack>
161163
</div>
162-
<div>
163-
<Link
164-
className={styles.scheduleAction}
165-
component={RouterLink}
166-
to={`/@${workspace.owner_name}/${workspace.name}/schedule`}
167-
>
168-
{Language.editScheduleLink}
169-
</Link>
170-
</div>
164+
{canUpdateWorkspace && (
165+
<div>
166+
<Link
167+
className={styles.scheduleAction}
168+
component={RouterLink}
169+
to={`/@${workspace.owner_name}/${workspace.name}/schedule`}
170+
>
171+
{Language.editScheduleLink}
172+
</Link>
173+
</div>
174+
)}
171175
</Stack>
172176
</div>
173177
)

site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx

+23-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { useMachine } from "@xstate/react"
1+
import { useMachine, useSelector } from "@xstate/react"
22
import * as cronParser from "cron-parser"
33
import dayjs from "dayjs"
44
import timezone from "dayjs/plugin/timezone"
55
import utc from "dayjs/plugin/utc"
6-
import React, { useEffect } from "react"
6+
import React, { useContext, useEffect } from "react"
77
import { useNavigate, useParams } from "react-router-dom"
88
import * as TypesGen from "../../api/typesGenerated"
99
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
@@ -16,6 +16,8 @@ import {
1616
} from "../../components/WorkspaceScheduleForm/WorkspaceScheduleForm"
1717
import { firstOrItem } from "../../util/array"
1818
import { extractTimezone, stripTimezone } from "../../util/schedule"
19+
import { selectUser } from "../../xServices/auth/authSelectors"
20+
import { XServiceContext } from "../../xServices/StateContext"
1921
import { workspaceSchedule } from "../../xServices/workspaceSchedule/workspaceScheduleXService"
2022

2123
// REMARK: timezone plugin depends on UTC
@@ -24,6 +26,10 @@ import { workspaceSchedule } from "../../xServices/workspaceSchedule/workspaceSc
2426
dayjs.extend(utc)
2527
dayjs.extend(timezone)
2628

29+
const Language = {
30+
forbiddenError: "403: Workspace schedule update forbidden.",
31+
}
32+
2733
export const formValuesToAutoStartRequest = (
2834
values: WorkspaceScheduleFormValues,
2935
): TypesGen.UpdateWorkspaceAutostartRequest => {
@@ -141,8 +147,17 @@ export const WorkspaceSchedulePage: React.FC = () => {
141147
const navigate = useNavigate()
142148
const username = firstOrItem(usernameQueryParam, null)
143149
const workspaceName = firstOrItem(workspaceQueryParam, null)
144-
const [scheduleState, scheduleSend] = useMachine(workspaceSchedule)
145-
const { formErrors, getWorkspaceError, workspace } = scheduleState.context
150+
151+
const xServices = useContext(XServiceContext)
152+
const me = useSelector(xServices.authXService, selectUser)
153+
154+
const [scheduleState, scheduleSend] = useMachine(workspaceSchedule, {
155+
context: {
156+
userId: me?.id,
157+
},
158+
})
159+
const { checkPermissionsError, formErrors, getWorkspaceError, permissions, workspace } =
160+
scheduleState.context
146161

147162
// Get workspace on mount and whenever the args for getting a workspace change.
148163
// scheduleSend should not change.
@@ -156,16 +171,19 @@ export const WorkspaceSchedulePage: React.FC = () => {
156171
} else if (
157172
scheduleState.matches("idle") ||
158173
scheduleState.matches("gettingWorkspace") ||
174+
scheduleState.matches("gettingPermissions") ||
159175
!workspace
160176
) {
161177
return <FullScreenLoader />
162178
} else if (scheduleState.matches("error")) {
163179
return (
164180
<ErrorSummary
165-
error={getWorkspaceError}
181+
error={getWorkspaceError || checkPermissionsError}
166182
retry={() => scheduleSend({ type: "GET_WORKSPACE", username, workspaceName })}
167183
/>
168184
)
185+
} else if (!permissions?.updateWorkspace) {
186+
return <ErrorSummary error={Error(Language.forbiddenError)} />
169187
} else if (scheduleState.matches("presentForm") || scheduleState.matches("submittingSchedule")) {
170188
return (
171189
<WorkspaceScheduleForm

site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts

+62-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export const Language = {
1414
successMessage: "Successfully updated workspace schedule.",
1515
}
1616

17+
type Permissions = Record<keyof ReturnType<typeof permissionsToCheck>, boolean>
18+
1719
export interface WorkspaceScheduleContext {
1820
formErrors?: FieldErrors
1921
getWorkspaceError?: Error | unknown
@@ -23,8 +25,27 @@ export interface WorkspaceScheduleContext {
2325
* machine is partially influenced by workspaceXService.
2426
*/
2527
workspace?: TypesGen.Workspace
28+
// permissions
29+
userId?: string
30+
permissions?: Permissions
31+
checkPermissionsError?: Error | unknown
2632
}
2733

34+
export const checks = {
35+
updateWorkspace: "updateWorkspace",
36+
} as const
37+
38+
const permissionsToCheck = (workspace: TypesGen.Workspace) => ({
39+
[checks.updateWorkspace]: {
40+
object: {
41+
resource_type: "workspace",
42+
resource_id: workspace.id,
43+
owner_id: workspace.owner_id,
44+
},
45+
action: "update",
46+
},
47+
})
48+
2849
export type WorkspaceScheduleEvent =
2950
| { type: "GET_WORKSPACE"; username: string; workspaceName: string }
3051
| {
@@ -60,7 +81,7 @@ export const workspaceSchedule = createMachine(
6081
src: "getWorkspace",
6182
id: "getWorkspace",
6283
onDone: {
63-
target: "presentForm",
84+
target: "gettingPermissions",
6485
actions: ["assignWorkspace"],
6586
},
6687
onError: {
@@ -70,6 +91,25 @@ export const workspaceSchedule = createMachine(
7091
},
7192
tags: "loading",
7293
},
94+
gettingPermissions: {
95+
entry: "clearGetPermissionsError",
96+
invoke: {
97+
src: "checkPermissions",
98+
id: "checkPermissions",
99+
onDone: [
100+
{
101+
actions: ["assignPermissions"],
102+
target: "presentForm",
103+
},
104+
],
105+
onError: [
106+
{
107+
actions: "assignGetPermissionsError",
108+
target: "error",
109+
},
110+
],
111+
},
112+
},
73113
presentForm: {
74114
on: {
75115
SUBMIT_SCHEDULE: "submittingSchedule",
@@ -113,8 +153,19 @@ export const workspaceSchedule = createMachine(
113153
assignGetWorkspaceError: assign({
114154
getWorkspaceError: (_, event) => event.data,
115155
}),
156+
assignPermissions: assign({
157+
// Setting event.data as Permissions to be more stricted. So we know
158+
// what permissions we asked for.
159+
permissions: (_, event) => event.data as Permissions,
160+
}),
161+
assignGetPermissionsError: assign({
162+
checkPermissionsError: (_, event) => event.data,
163+
}),
164+
clearGetPermissionsError: assign({
165+
checkPermissionsError: (_) => undefined,
166+
}),
116167
clearContext: () => {
117-
assign({ workspace: undefined })
168+
assign({ workspace: undefined, permissions: undefined })
118169
},
119170
clearGetWorkspaceError: (context) => {
120171
assign({ ...context, getWorkspaceError: undefined })
@@ -134,6 +185,15 @@ export const workspaceSchedule = createMachine(
134185
getWorkspace: async (_, event) => {
135186
return await API.getWorkspaceByOwnerAndName(event.username, event.workspaceName)
136187
},
188+
checkPermissions: async (context) => {
189+
if (context.workspace && context.userId) {
190+
return await API.checkUserPermissions(context.userId, {
191+
checks: permissionsToCheck(context.workspace),
192+
})
193+
} else {
194+
throw Error("Cannot check permissions without both workspace and user id")
195+
}
196+
},
137197
submitSchedule: async (context, event) => {
138198
if (!context.workspace?.id) {
139199
// This state is theoretically impossible, but helps TS

0 commit comments

Comments
 (0)