Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit c1b3080

Browse files
authored
fix: restrict edit schedule access (coder#2698)
1 parent ea5c2cd commit c1b3080

File tree

7 files changed

+164
-29
lines changed

7 files changed

+164
-29
lines changed

site/src/components/Workspace/Workspace.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const Workspace: FC<WorkspaceProps> = ({
6464
workspace={workspace}
6565
onDeadlineMinus={scheduleProps.onDeadlineMinus}
6666
onDeadlinePlus={scheduleProps.onDeadlinePlus}
67+
canUpdateWorkspace={canUpdateWorkspace}
6768
/>
6869
<WorkspaceActions
6970
workspace={workspace}

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

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

3434
export interface WorkspaceScheduleProps {
3535
workspace: Workspace
36+
canUpdateWorkspace: boolean
3637
}
3738

38-
export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({ workspace }) => {
39+
export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({
40+
workspace,
41+
canUpdateWorkspace,
42+
}) => {
3943
const styles = useStyles()
4044
const timezone = workspace.autostart_schedule
4145
? extractTimezone(workspace.autostart_schedule)
@@ -62,15 +66,17 @@ export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({ workspace }) =>
6266
</span>
6367
</Stack>
6468
</div>
65-
<div>
66-
<Link
67-
className={styles.scheduleAction}
68-
component={RouterLink}
69-
to={`/@${workspace.owner_name}/${workspace.name}/schedule`}
70-
>
71-
{Language.editScheduleLink}
72-
</Link>
73-
</div>
69+
{canUpdateWorkspace && (
70+
<div>
71+
<Link
72+
className={styles.scheduleAction}
73+
component={RouterLink}
74+
to={`/@${workspace.owner_name}/${workspace.name}/schedule`}
75+
>
76+
{Language.editScheduleLink}
77+
</Link>
78+
</div>
79+
)}
7480
</Stack>
7581
</div>
7682
)

site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx

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

2126
const Template: Story<WorkspaceScheduleButtonProps> = (args) => (
@@ -115,3 +120,17 @@ WorkspaceOffLong.args = {
115120
ttl_ms: 2 * 365 * 24 * 60 * 60 * 1000, // 2 years
116121
},
117122
}
123+
124+
export const CannotEdit = Template.bind({})
125+
CannotEdit.args = {
126+
workspace: {
127+
...Mocks.MockWorkspace,
128+
129+
latest_build: {
130+
...Mocks.MockWorkspaceBuild,
131+
transition: "stop",
132+
},
133+
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours
134+
},
135+
canUpdateWorkspace: false,
136+
}

site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,14 @@ export interface WorkspaceScheduleButtonProps {
5454
workspace: Workspace
5555
onDeadlinePlus: () => void
5656
onDeadlineMinus: () => void
57+
canUpdateWorkspace: boolean
5758
}
5859

5960
export const WorkspaceScheduleButton: React.FC<WorkspaceScheduleButtonProps> = ({
6061
workspace,
6162
onDeadlinePlus,
6263
onDeadlineMinus,
64+
canUpdateWorkspace,
6365
}) => {
6466
const anchorRef = useRef<HTMLButtonElement>(null)
6567
const [isOpen, setIsOpen] = useState(false)
@@ -74,7 +76,7 @@ export const WorkspaceScheduleButton: React.FC<WorkspaceScheduleButtonProps> = (
7476
<div className={styles.wrapper}>
7577
<div className={styles.label}>
7678
<WorkspaceScheduleLabel workspace={workspace} />
77-
{shouldDisplayPlusMinus(workspace) && (
79+
{canUpdateWorkspace && shouldDisplayPlusMinus(workspace) && (
7880
<Stack direction="row" spacing={0}>
7981
<IconButton
8082
className={styles.iconButton}
@@ -124,7 +126,7 @@ export const WorkspaceScheduleButton: React.FC<WorkspaceScheduleButtonProps> = (
124126
horizontal: "right",
125127
}}
126128
>
127-
<WorkspaceSchedule workspace={workspace} />
129+
<WorkspaceSchedule workspace={workspace} canUpdateWorkspace={canUpdateWorkspace} />
128130
</Popover>
129131
</div>
130132
</div>

site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx

+42-14
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: "You don't have permissions to update the schedule for this workspace.",
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.
@@ -153,20 +168,31 @@ export const WorkspaceSchedulePage: React.FC = () => {
153168
if (!username || !workspaceName) {
154169
navigate("/workspaces")
155170
return null
156-
} else if (
171+
}
172+
173+
if (
157174
scheduleState.matches("idle") ||
158175
scheduleState.matches("gettingWorkspace") ||
176+
scheduleState.matches("gettingPermissions") ||
159177
!workspace
160178
) {
161179
return <FullScreenLoader />
162-
} else if (scheduleState.matches("error")) {
180+
}
181+
182+
if (scheduleState.matches("error")) {
163183
return (
164184
<ErrorSummary
165-
error={getWorkspaceError}
185+
error={getWorkspaceError || checkPermissionsError}
166186
retry={() => scheduleSend({ type: "GET_WORKSPACE", username, workspaceName })}
167187
/>
168188
)
169-
} else if (scheduleState.matches("presentForm") || scheduleState.matches("submittingSchedule")) {
189+
}
190+
191+
if (!permissions?.updateWorkspace) {
192+
return <ErrorSummary error={Error(Language.forbiddenError)} />
193+
}
194+
195+
if (scheduleState.matches("presentForm") || scheduleState.matches("submittingSchedule")) {
170196
return (
171197
<WorkspaceScheduleForm
172198
fieldErrors={formErrors}
@@ -184,13 +210,15 @@ export const WorkspaceSchedulePage: React.FC = () => {
184210
}}
185211
/>
186212
)
187-
} else if (scheduleState.matches("submitSuccess")) {
213+
}
214+
215+
if (scheduleState.matches("submitSuccess")) {
188216
navigate(`/@${username}/${workspaceName}`)
189217
return <FullScreenLoader />
190-
} else {
191-
// Theoretically impossible - log and bail
192-
console.error("WorkspaceSchedulePage: unknown state :: ", scheduleState)
193-
navigate("/")
194-
return null
195218
}
219+
220+
// Theoretically impossible - log and bail
221+
console.error("WorkspaceSchedulePage: unknown state :: ", scheduleState)
222+
navigate("/")
223+
return null
196224
}

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)