Skip to content

Commit 60bbb98

Browse files
authored
Handle fleet viewers without permission to see SCIM tokens (#2957)
* handle 403 on scim token list * cleanup, center empty message, text-balance title * fix implicit any coming from apiqErrorsAllowed * rearrange some stuff * add test for scim empty state * remove one more apiQueryClient
1 parent f95e29f commit 60bbb98

File tree

7 files changed

+165
-125
lines changed

7 files changed

+165
-125
lines changed

app/api/hooks.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,8 @@ export const getApiQueryOptionsErrorsAllowed =
245245
queryFn: ({ signal }) =>
246246
api[method](params, { signal })
247247
.then(handleResult(method))
248-
.then((data) => ({ type: 'success' as const, data }))
249-
.catch((data) => ({ type: 'error' as const, data })),
248+
.then((data: Result<A[M]>) => ({ type: 'success' as const, data }))
249+
.catch((data: ApiError) => ({ type: 'error' as const, data })),
250250
...options,
251251
})
252252

app/pages/system/silos/SiloScimTab.tsx

Lines changed: 102 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,19 @@
99
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
1010
import { useCallback, useMemo, useState } from 'react'
1111
import { type LoaderFunctionArgs } from 'react-router'
12+
import * as R from 'remeda'
13+
import { match } from 'ts-pattern'
1214

1315
import { AccessToken24Icon } from '@oxide/design-system/icons/react'
1416
import { Badge } from '@oxide/design-system/ui'
1517

1618
import {
17-
apiQueryClient,
19+
apiqErrorsAllowed,
20+
queryClient,
1821
useApiMutation,
19-
usePrefetchedApiQuery,
22+
usePrefetchedQuery,
2023
type ScimClientBearerToken,
24+
type ScimClientBearerTokenValue,
2125
} from '~/api'
2226
import { makeCrumb } from '~/hooks/use-crumbs'
2327
import { getSiloSelector, useSiloSelector } from '~/hooks/use-params'
@@ -36,7 +40,24 @@ import { Modal } from '~/ui/lib/Modal'
3640
import { TableEmptyBox } from '~/ui/lib/Table'
3741
import { Truncate } from '~/ui/lib/Truncate'
3842

43+
export const handle = makeCrumb('SCIM')
44+
3945
const colHelper = createColumnHelper<ScimClientBearerToken>()
46+
const staticColumns = [
47+
colHelper.accessor('id', {
48+
header: 'ID',
49+
cell: (info) => <Truncate text={info.getValue()} position="middle" maxLength={18} />,
50+
}),
51+
colHelper.accessor('timeCreated', Columns.timeCreated),
52+
colHelper.accessor('timeExpires', {
53+
header: 'Expires',
54+
cell: (info) => {
55+
const expires = info.getValue()
56+
return expires ? <DateTime date={expires} /> : <Badge color="neutral">Never</Badge>
57+
},
58+
meta: { thClassName: 'lg:w-1/4' },
59+
}),
60+
]
4061

4162
const EmptyState = () => (
4263
<TableEmptyBox border={false}>
@@ -50,151 +71,131 @@ const EmptyState = () => (
5071

5172
export async function clientLoader({ params }: LoaderFunctionArgs) {
5273
const { silo } = getSiloSelector(params)
53-
await apiQueryClient.prefetchQuery('scimTokenList', { query: { silo } })
74+
// Use errors-allowed approach so 403s don't throw and break the loader
75+
await queryClient.prefetchQuery(apiqErrorsAllowed('scimTokenList', { query: { silo } }))
5476
return null
5577
}
5678

79+
type ModalState =
80+
| { kind: 'create' }
81+
| { kind: 'created'; token: ScimClientBearerTokenValue }
82+
| false
83+
5784
export default function SiloScimTab() {
5885
const siloSelector = useSiloSelector()
59-
const { data } = usePrefetchedApiQuery('scimTokenList', {
60-
query: { silo: siloSelector.silo },
61-
})
62-
63-
// Order tokens by creation date, oldest first
64-
const tokens = useMemo(
65-
() => [...data].sort((a, b) => a.timeCreated.getTime() - b.timeCreated.getTime()),
66-
[data]
86+
const { data: tokensResult } = usePrefetchedQuery(
87+
apiqErrorsAllowed('scimTokenList', { query: siloSelector })
6788
)
6889

69-
const [showCreateModal, setShowCreateModal] = useState(false)
70-
const [createdToken, setCreatedToken] = useState<{
71-
id: string
72-
bearerToken: string
73-
timeCreated: Date
74-
timeExpires?: Date | null
75-
} | null>(null)
90+
const [modalState, setModalState] = useState<ModalState>(false)
91+
92+
return (
93+
<>
94+
<CardBlock>
95+
<CardBlock.Header
96+
title="SCIM Tokens"
97+
titleId="scim-tokens-label"
98+
description="Tokens for authenticating requests to SCIM endpoints"
99+
>
100+
{
101+
// assume that if you can see the tokens, you can create tokens
102+
tokensResult.type === 'success' && (
103+
<CreateButton onClick={() => setModalState({ kind: 'create' })}>
104+
Create token
105+
</CreateButton>
106+
)
107+
}
108+
</CardBlock.Header>
109+
<CardBlock.Body>
110+
{match(tokensResult)
111+
.with({ type: 'error' }, () => (
112+
<TableEmptyBox border={false}>
113+
<EmptyMessage
114+
icon={<AccessToken24Icon />}
115+
title="You do not have permission to view SCIM tokens"
116+
body="Only fleet and silo admins can manage SCIM tokens for this silo"
117+
/>
118+
</TableEmptyBox>
119+
))
120+
.with({ type: 'success' }, ({ data }) => <TokensTable tokens={data} />)
121+
.exhaustive()}
122+
</CardBlock.Body>
123+
{/* TODO: put this back!
124+
<CardBlock.Footer>
125+
<LearnMore href={links.scimDocs} text="SCIM" />
126+
</CardBlock.Footer> */}
127+
</CardBlock>
128+
129+
{match(modalState)
130+
.with({ kind: 'create' }, () => (
131+
<CreateTokenModal
132+
siloSelector={siloSelector}
133+
onDismiss={() => setModalState(false)}
134+
onSuccess={(token) => setModalState({ kind: 'created', token })}
135+
/>
136+
))
137+
.with({ kind: 'created' }, ({ token }) => (
138+
<TokenCreatedModal token={token} onDismiss={() => setModalState(false)} />
139+
))
140+
.with(false, () => null)
141+
.exhaustive()}
142+
</>
143+
)
144+
}
76145

146+
function TokensTable({ tokens }: { tokens: ScimClientBearerToken[] }) {
147+
const siloSelector = useSiloSelector()
77148
const deleteToken = useApiMutation('scimTokenDelete', {
78149
onSuccess() {
79-
apiQueryClient.invalidateQueries('scimTokenList')
150+
queryClient.invalidateEndpoint('scimTokenList')
80151
},
81152
})
82153

154+
// Order tokens by creation date, oldest first
155+
const sortedTokens = useMemo(() => R.sortBy(tokens, (a) => a.timeCreated), [tokens])
156+
83157
const makeActions = useCallback(
84158
(token: ScimClientBearerToken): MenuAction[] => [
85159
{
86160
label: 'Delete',
87161
onActivate: confirmDelete({
88162
doDelete: () =>
89-
deleteToken.mutateAsync({
90-
path: { tokenId: token.id },
91-
query: { silo: siloSelector.silo },
92-
}),
163+
deleteToken.mutateAsync({ path: { tokenId: token.id }, query: siloSelector }),
93164
resourceKind: 'SCIM token',
94165
label: token.id,
95166
}),
96167
},
97168
],
98-
[deleteToken, siloSelector.silo]
99-
)
100-
101-
const staticColumns = useMemo(
102-
() => [
103-
colHelper.accessor('id', {
104-
header: 'ID',
105-
cell: (info) => (
106-
<Truncate text={info.getValue()} position="middle" maxLength={18} />
107-
),
108-
}),
109-
colHelper.accessor('timeCreated', Columns.timeCreated),
110-
colHelper.accessor('timeExpires', {
111-
header: 'Expires',
112-
cell: (info) => {
113-
const expires = info.getValue()
114-
return expires ? (
115-
<DateTime date={expires} />
116-
) : (
117-
<Badge color="neutral">Never</Badge>
118-
)
119-
},
120-
meta: { thClassName: 'lg:w-1/4' },
121-
}),
122-
],
123-
[]
169+
[deleteToken, siloSelector]
124170
)
125171

126172
const columns = useColsWithActions(staticColumns, makeActions, 'Copy token ID')
127173

128174
const table = useReactTable({
129-
data: tokens,
175+
data: sortedTokens,
130176
columns,
131177
getCoreRowModel: getCoreRowModel(),
132178
})
133-
// const { href, linkText } = docLinks.scim
134-
return (
135-
<>
136-
<CardBlock>
137-
<CardBlock.Header
138-
title="SCIM Tokens"
139-
titleId="scim-tokens-label"
140-
description="Tokens for authenticating requests to SCIM endpoints"
141-
>
142-
<CreateButton onClick={() => setShowCreateModal(true)}>Create token</CreateButton>
143-
</CardBlock.Header>
144-
<CardBlock.Body>
145-
{tokens.length === 0 ? (
146-
<EmptyState />
147-
) : (
148-
<Table
149-
aria-labelledby="scim-tokens-label"
150-
table={table}
151-
className="table-inline"
152-
/>
153-
)}
154-
</CardBlock.Body>
155-
{/* TODO: put this back!
156-
<CardBlock.Footer>
157-
<LearnMore href={links.scimDocs} text="SCIM" />
158-
</CardBlock.Footer> */}
159-
</CardBlock>
160179

161-
{showCreateModal && (
162-
<CreateTokenModal
163-
siloSelector={siloSelector}
164-
onDismiss={() => setShowCreateModal(false)}
165-
onSuccess={(token) => {
166-
setShowCreateModal(false)
167-
setCreatedToken(token)
168-
}}
169-
/>
170-
)}
180+
if (sortedTokens.length === 0) return <EmptyState />
171181

172-
{createdToken && (
173-
<TokenCreatedModal token={createdToken} onDismiss={() => setCreatedToken(null)} />
174-
)}
175-
</>
182+
return (
183+
<Table aria-labelledby="scim-tokens-label" table={table} className="table-inline" />
176184
)
177185
}
178186

179-
export const handle = makeCrumb('SCIM')
180-
181187
function CreateTokenModal({
182188
siloSelector,
183189
onDismiss,
184190
onSuccess,
185191
}: {
186192
siloSelector: { silo: string }
187193
onDismiss: () => void
188-
onSuccess: (token: {
189-
id: string
190-
bearerToken: string
191-
timeCreated: Date
192-
timeExpires?: Date | null
193-
}) => void
194+
onSuccess: (token: ScimClientBearerTokenValue) => void
194195
}) {
195196
const createToken = useApiMutation('scimTokenCreate', {
196197
onSuccess(token) {
197-
apiQueryClient.invalidateQueries('scimTokenList')
198+
queryClient.invalidateEndpoint('scimTokenList')
198199
onSuccess(token)
199200
},
200201
onError(err) {
@@ -226,12 +227,7 @@ function TokenCreatedModal({
226227
token,
227228
onDismiss,
228229
}: {
229-
token: {
230-
id: string
231-
bearerToken: string
232-
timeCreated: Date
233-
timeExpires?: Date | null
234-
}
230+
token: ScimClientBearerTokenValue
235231
onDismiss: () => void
236232
}) {
237233
return (

app/ui/lib/EmptyMessage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,11 @@ export function EmptyMessage(props: Props) {
4343
return (
4444
<div className="m-4 flex max-w-[18rem] flex-col items-center text-center">
4545
{props.icon && (
46-
<div className="text-accent bg-accent-secondary mb-4 rounded p-1 leading-[0]">
46+
<div className="text-accent bg-accent-secondary mb-4 rounded p-1 leading-0">
4747
{props.icon}
4848
</div>
4949
)}
50-
<h3 className="text-sans-semi-lg">{props.title}</h3>
50+
<h3 className="text-sans-semi-lg text-balance">{props.title}</h3>
5151
{typeof props.body === 'string' ? <EMBody>{props.body}</EMBody> : props.body}
5252
{button}
5353
</div>

mock-api/msw/handlers.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
paginated,
5656
randomHex,
5757
requireFleetAdmin,
58+
requireFleetAdminOrSiloAdmin,
5859
requireFleetCollab,
5960
requireFleetViewer,
6061
requireRole,
@@ -1892,17 +1893,17 @@ export const handlers = makeHandlers({
18921893

18931894
// SCIM token endpoints
18941895
scimTokenList({ query, cookies }) {
1895-
requireFleetViewer(cookies)
18961896
const silo = lookup.silo({ silo: query.silo })
1897+
requireFleetAdminOrSiloAdmin(cookies, silo.id)
18971898
// Filter by silo and strip out the siloId before returning
18981899
const tokens = db.scimTokens
18991900
.filter((t) => t.siloId === silo.id)
19001901
.map(({ siloId: _siloId, ...token }) => token)
19011902
return tokens
19021903
},
19031904
scimTokenCreate({ query, cookies }) {
1904-
requireFleetCollab(cookies)
19051905
const silo = lookup.silo({ silo: query.silo })
1906+
requireFleetAdminOrSiloAdmin(cookies, silo.id)
19061907

19071908
const newToken: Json<Api.ScimClientBearerTokenValue> = {
19081909
id: uuid(),
@@ -1917,15 +1918,15 @@ export const handlers = makeHandlers({
19171918
return json(newToken, { status: 201 })
19181919
},
19191920
scimTokenView({ path, cookies }) {
1920-
requireFleetViewer(cookies)
19211921
const token = lookupById(db.scimTokens, path.tokenId)
1922+
requireFleetAdminOrSiloAdmin(cookies, token.siloId)
19221923
// Strip out siloId before returning
19231924
const { siloId: _siloId, ...tokenResponse } = token
19241925
return tokenResponse
19251926
},
19261927
scimTokenDelete({ path, cookies }) {
1927-
requireFleetCollab(cookies)
19281928
const token = lookupById(db.scimTokens, path.tokenId)
1929+
requireFleetAdminOrSiloAdmin(cookies, token.siloId)
19291930
db.scimTokens = db.scimTokens.filter((t) => t.id !== token.id)
19301931
return 204
19311932
},

mock-api/msw/util.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,20 @@ export function requireFleetAdmin(cookies: Record<string, string>) {
359359
requireRole(cookies, 'fleet', FLEET_ID, 'admin')
360360
}
361361

362+
/**
363+
* Determine whether current user has fleet admin OR silo admin on a specific
364+
* silo. Used for SCIM token operations. Do nothing if yes, throw 403 if no.
365+
*/
366+
export function requireFleetAdminOrSiloAdmin(
367+
cookies: Record<string, string>,
368+
siloId: string
369+
) {
370+
const user = currentUser(cookies)
371+
const hasFleetAdmin = userHasRole(user, 'fleet', FLEET_ID, 'admin')
372+
const hasSiloAdmin = userHasRole(user, 'silo', siloId, 'admin')
373+
if (!hasFleetAdmin && !hasSiloAdmin) throw forbiddenErr()
374+
}
375+
362376
/**
363377
* Determine whether current user has a role on a resource by looking roles
364378
* for the user as well as for the user's groups. Do nothing if yes, throw 403

mock-api/silo.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,4 @@ export const scimTokens: DbScimToken[] = [
146146
time_expires: null,
147147
siloId: defaultSilo.id,
148148
},
149-
{
150-
id: 'c3d4e5f6-a7b8-9012-cdef-123456789012',
151-
time_created: new Date(2025, 8, 10).toISOString(),
152-
time_expires: null,
153-
siloId: silos[1].id,
154-
},
155149
]

0 commit comments

Comments
 (0)