Skip to content

Commit 0acd837

Browse files
committed
fix: restrict edit schedule access
1 parent a494489 commit 0acd837

File tree

5 files changed

+122
-19
lines changed

5 files changed

+122
-19
lines changed

site/src/components/Workspace/Workspace.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export const Workspace: FC<WorkspaceProps> = ({
9999
</Stack>
100100

101101
<Stack direction="column" className={styles.secondColumnSpacer} spacing={3}>
102-
<WorkspaceSchedule workspace={workspace} />
102+
<WorkspaceSchedule workspace={workspace} canUpdateWorkspace={canUpdateWorkspace} />
103103
</Stack>
104104
</Stack>
105105
</Margins>

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

+20-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ const SEVEN = 7
1515
export default {
1616
title: "components/WorkspaceSchedule",
1717
component: WorkspaceSchedule,
18+
argTypes: {
19+
canUpdateWorkspace: {
20+
defaultValue: true,
21+
},
22+
},
1823
}
1924

2025
const Template: Story<WorkspaceScheduleProps> = (args) => <WorkspaceSchedule {...args} />
@@ -39,7 +44,7 @@ NoTTL.args = {
3944
...Mocks.MockWorkspace,
4045
latest_build: {
4146
...Mocks.MockWorkspaceBuild,
42-
// a mannual shutdown has a deadline of '"0001-01-01T00:00:00Z"'
47+
// a manual shutdown has a deadline of '"0001-01-01T00:00:00Z"'
4348
// SEE: #1834
4449
deadline: "0001-01-01T00:00:00Z",
4550
},
@@ -99,3 +104,17 @@ WorkspaceOffLong.args = {
99104
ttl_ms: 2 * 365 * 24 * 60 * 60 * 1000, // 2 years
100105
},
101106
}
107+
108+
export const CannotEdit = Template.bind({})
109+
CannotEdit.args = {
110+
workspace: {
111+
...Mocks.MockWorkspace,
112+
113+
latest_build: {
114+
...Mocks.MockWorkspaceBuild,
115+
transition: "stop",
116+
},
117+
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours
118+
},
119+
canUpdateWorkspace: false,
120+
}

site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx

+16-10
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,13 @@ export const Language = {
7676

7777
export interface WorkspaceScheduleProps {
7878
workspace: Workspace
79+
canUpdateWorkspace: boolean
7980
}
8081

81-
export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({ workspace }) => {
82+
export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({
83+
workspace,
84+
canUpdateWorkspace,
85+
}) => {
8286
const styles = useStyles()
8387

8488
return (
@@ -100,15 +104,17 @@ export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({ workspace }) =>
100104
{Language.autoStopDisplay(workspace)}
101105
</span>
102106
</div>
103-
<div>
104-
<Link
105-
className={styles.scheduleAction}
106-
component={RouterLink}
107-
to={`/@${workspace.owner_name}/${workspace.name}/schedule`}
108-
>
109-
{Language.editScheduleLink}
110-
</Link>
111-
</div>
107+
{canUpdateWorkspace && (
108+
<div>
109+
<Link
110+
className={styles.scheduleAction}
111+
component={RouterLink}
112+
to={`/@${workspace.owner_name}/${workspace.name}/schedule`}
113+
>
114+
{Language.editScheduleLink}
115+
</Link>
116+
</div>
117+
)}
112118
</Stack>
113119
</div>
114120
)

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)