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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 20 additions & 1 deletion
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

Lines changed: 16 additions & 10 deletions
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

Lines changed: 23 additions & 5 deletions
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

Lines changed: 62 additions & 2 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy