Skip to content

Commit c1b3080

Browse files
authored
fix: restrict edit schedule access (#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

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

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

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

Lines changed: 19 additions & 0 deletions
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

Lines changed: 4 additions & 2 deletions
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

Lines changed: 42 additions & 14 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: "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

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