JitDbAccessRuleSheet.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { useParams } from 'common'
  3. import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
  4. import { useEffect, useMemo } from 'react'
  5. import { useForm } from 'react-hook-form'
  6. import { toast } from 'sonner'
  7. import {
  8. Button,
  9. Form,
  10. FormControl,
  11. FormField,
  12. ScrollArea,
  13. Select,
  14. SelectContent,
  15. SelectItem,
  16. SelectTrigger,
  17. SelectValue,
  18. Sheet,
  19. SheetContent,
  20. SheetDescription,
  21. SheetFooter,
  22. SheetHeader,
  23. SheetTitle,
  24. } from 'ui'
  25. import { Admonition } from 'ui-patterns'
  26. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  27. import { z } from 'zod'
  28. import type {
  29. JitExpiryMode,
  30. JitMemberOption,
  31. JitUserRuleDraft,
  32. SheetMode,
  33. } from './JitDbAccess.types'
  34. import {
  35. createDraft,
  36. draftFromRule,
  37. getAssignableJitRoleOptions,
  38. getInvalidIpRangeRows,
  39. mapJitMembersToUserRules,
  40. serializeDraftRolesForGrantMutation,
  41. } from './JitDbAccess.utils'
  42. import { JitDbAccessRoleGrantFields } from './JitDbAccessRoleGrantFields'
  43. import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
  44. import { InlineLink } from '@/components/ui/InlineLink'
  45. import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
  46. import { useJitDbAccessGrantMutation } from '@/data/jit-db-access/jit-db-access-grant-mutation'
  47. import { useJitDbAccessMembersQuery } from '@/data/jit-db-access/jit-db-access-members-query'
  48. import { useProjectMembersQuery } from '@/data/projects/project-members-query'
  49. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  50. import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
  51. import { DOCS_URL } from '@/lib/constants'
  52. const grantSchema = z.object({
  53. roleId: z.string(),
  54. enabled: z.boolean(),
  55. branchesOnly: z.boolean(),
  56. expiryMode: z.custom<JitExpiryMode>(),
  57. hasExpiry: z.boolean(),
  58. expiry: z.string(),
  59. ipRanges: z.array(z.object({ value: z.string() })),
  60. })
  61. function createJitRuleSchema(mode: SheetMode, membersWithRules: Set<string>) {
  62. return z
  63. .object({
  64. memberId: z.string().min(1, 'Select a member for this temporary access rule.'),
  65. grants: z.array(grantSchema),
  66. })
  67. .superRefine((data, ctx) => {
  68. if (mode === 'add' && membersWithRules.has(data.memberId)) {
  69. ctx.addIssue({
  70. code: z.ZodIssueCode.custom,
  71. path: ['memberId'],
  72. message:
  73. 'This member already has a temporary access rule. Edit their existing rule from the list.',
  74. })
  75. }
  76. const enabledGrantCount = data.grants.filter((g) => g.enabled).length
  77. if (enabledGrantCount === 0) {
  78. ctx.addIssue({
  79. code: z.ZodIssueCode.custom,
  80. path: ['grants'],
  81. message: 'Select at least one role.',
  82. })
  83. return
  84. }
  85. data.grants.forEach((grant, grantIndex) => {
  86. if (!grant.enabled) return
  87. const invalidCidrs = new Set(getInvalidIpRangeRows(grant.ipRanges))
  88. grant.ipRanges.forEach((ipRange, ipRangeIndex) => {
  89. const value = ipRange.value.trim()
  90. if (value.length === 0 || !invalidCidrs.has(value)) return
  91. ctx.addIssue({
  92. code: z.ZodIssueCode.custom,
  93. path: ['grants', grantIndex, 'ipRanges', ipRangeIndex, 'value'],
  94. message: 'Please enter a valid CIDR range',
  95. })
  96. })
  97. })
  98. })
  99. }
  100. interface JitDbAccessRuleSheetProps {
  101. memberOptions: JitMemberOption[]
  102. membersWithRules: Set<string>
  103. availableMembersForAddCount: number
  104. }
  105. /**
  106. * [Joshen] Form schema can be further refactored to simplify
  107. * It's weird that we're rendering the role options based on the form, when it's just based on
  108. * the available database roles - should decouple
  109. */
  110. export function JitDbAccessRuleSheet({
  111. memberOptions,
  112. membersWithRules,
  113. availableMembersForAddCount,
  114. }: JitDbAccessRuleSheetProps) {
  115. const { ref: projectRef } = useParams()
  116. const { data: project } = useSelectedProjectQuery()
  117. const [isNewRule, setIsNewRule] = useQueryState('jit_new', parseAsBoolean.withDefault(false))
  118. const [ruleIdToEdit, setRuleIdToEdit] = useQueryState('jit_edit', parseAsString)
  119. const { data: databaseRoles, isSuccess: isSuccessDatabaseRoles } = useDatabaseRolesQuery({
  120. projectRef,
  121. connectionString: project?.connectionString,
  122. })
  123. const roleOptions = useMemo(() => getAssignableJitRoleOptions(databaseRoles), [databaseRoles])
  124. const roleIds = useMemo(() => roleOptions.map((role) => role.id), [roleOptions])
  125. const { data: jitMembers, isSuccess: isSuccessJitMembers } = useJitDbAccessMembersQuery({
  126. projectRef,
  127. })
  128. const { data: projectMembers, isSuccess: isSuccessProjectMembers } = useProjectMembersQuery({
  129. projectRef,
  130. })
  131. const users = useMemo(
  132. () => mapJitMembersToUserRules(jitMembers, projectMembers, roleOptions),
  133. [jitMembers, projectMembers, roleOptions]
  134. )
  135. const user = users.find((x) => x.id === ruleIdToEdit)
  136. const mode: SheetMode = !!user ? 'edit' : 'add'
  137. const isDataReady = isSuccessDatabaseRoles && isSuccessJitMembers && isSuccessProjectMembers
  138. const open = isNewRule || (!!ruleIdToEdit && !!user)
  139. const defaultValues = !isNewRule && !!user ? draftFromRule(user, roleIds) : createDraft(roleIds)
  140. const FormSchema = useMemo(
  141. () => createJitRuleSchema(mode, membersWithRules),
  142. [mode, membersWithRules]
  143. )
  144. const form = useForm<JitUserRuleDraft>({
  145. defaultValues,
  146. resolver: zodResolver(FormSchema as any),
  147. })
  148. const grants = form.watch('grants')
  149. const onCloseSheet = () => {
  150. setIsNewRule(false)
  151. setRuleIdToEdit(null)
  152. }
  153. const {
  154. confirmOnClose,
  155. handleOpenChange,
  156. modalProps: closeConfirmationModalProps,
  157. } = useConfirmOnClose({
  158. checkIsDirty: () => form.formState.isDirty,
  159. onClose: onCloseSheet,
  160. })
  161. const { mutate: grantUserAccess, isPending: isSubmitting } = useJitDbAccessGrantMutation({
  162. onSuccess: () => {
  163. toast.success(
  164. mode === 'edit' ? 'Successfully updated user access' : 'Successfully granted user access'
  165. )
  166. onCloseSheet()
  167. },
  168. onError: (error) => {
  169. toast.error(`Failed to ${mode === 'edit' ? 'update' : 'grant'} user access: ${error.message}`)
  170. },
  171. })
  172. const updateGrant = (
  173. roleId: string,
  174. updater: (grant: JitUserRuleDraft['grants'][number]) => JitUserRuleDraft['grants'][number]
  175. ) => {
  176. const nextGrants = grants.map((grant) => (grant.roleId === roleId ? updater(grant) : grant))
  177. form.setValue('grants', nextGrants, { shouldDirty: true })
  178. }
  179. const handleSaveRule = (data: z.infer<typeof FormSchema>) => {
  180. if (!projectRef) return console.error('Project ref is required')
  181. const roles = serializeDraftRolesForGrantMutation(data)
  182. if (roles.length === 0) return
  183. grantUserAccess({ projectRef, userId: data.memberId, roles })
  184. }
  185. useEffect(() => {
  186. if (!!ruleIdToEdit && isDataReady && !user) {
  187. toast('Access rule cannot be found')
  188. setRuleIdToEdit(null)
  189. }
  190. }, [isDataReady, ruleIdToEdit, setRuleIdToEdit, user])
  191. useEffect(() => {
  192. if (open && isDataReady) form.reset(defaultValues)
  193. // eslint-disable-next-line react-hooks/exhaustive-deps
  194. }, [open, isDataReady])
  195. return (
  196. <>
  197. <Sheet open={open} onOpenChange={handleOpenChange}>
  198. <SheetContent
  199. showClose={false}
  200. size="default"
  201. className="flex h-full w-full max-w-full flex-col gap-0 sm:w-[560px]! sm:max-w-[560px]"
  202. >
  203. <SheetHeader>
  204. <SheetTitle>
  205. {mode === 'edit' ? 'Edit temporary access rule' : 'New temporary access rule'}
  206. </SheetTitle>
  207. <SheetDescription className="sr-only">
  208. Configure which database roles a user can request with temporary access.
  209. </SheetDescription>
  210. </SheetHeader>
  211. <Form {...form}>
  212. <ScrollArea className="flex-1 max-h-[calc(100vh-116px)]">
  213. <div className="space-y-8 px-5 py-6 sm:px-6">
  214. <FormField
  215. control={form.control}
  216. name="memberId"
  217. render={({ field }) => (
  218. <FormItemLayout layout="vertical" label="Member">
  219. <FormControl>
  220. <Select
  221. value={field.value}
  222. disabled={
  223. mode === 'edit' || (mode === 'add' && availableMembersForAddCount === 0)
  224. }
  225. onValueChange={field.onChange}
  226. >
  227. <SelectTrigger>
  228. <SelectValue placeholder="Select a member" />
  229. </SelectTrigger>
  230. <SelectContent>
  231. {memberOptions.map((member) => (
  232. <SelectItem key={member.id} value={member.id}>
  233. {member.name ? (
  234. <>
  235. {member.name}{' '}
  236. <span className="text-foreground-lighter">
  237. ({member.email})
  238. </span>
  239. </>
  240. ) : (
  241. member.email
  242. )}
  243. </SelectItem>
  244. ))}
  245. </SelectContent>
  246. </Select>
  247. </FormControl>
  248. {mode === 'add' && availableMembersForAddCount === 0 && (
  249. <p className="mt-2 text-foreground-lighter">
  250. All project members already have temporary access rules. Edit an existing
  251. rule from the table above.
  252. </p>
  253. )}
  254. </FormItemLayout>
  255. )}
  256. />
  257. <FormField
  258. control={form.control}
  259. name="grants"
  260. render={() => (
  261. <FormItemLayout
  262. layout="vertical"
  263. label="Roles and settings"
  264. description={
  265. <>
  266. Use{' '}
  267. <InlineLink
  268. href={`${DOCS_URL}/guides/database/postgres/roles`}
  269. className="decoration-foreground-muted"
  270. >
  271. custom Postgres roles
  272. </InlineLink>{' '}
  273. with narrow permissions to reduce the impact of direct database access.
  274. </>
  275. }
  276. >
  277. {grants.length === 0 ? (
  278. <Admonition
  279. type="note"
  280. description="No assignable roles found."
  281. className="bg-background"
  282. />
  283. ) : (
  284. <div className="overflow-hidden rounded-md border">
  285. {grants.map((grant, index) => (
  286. <div key={grant.roleId} className={index > 0 ? 'border-t' : ''}>
  287. <JitDbAccessRoleGrantFields
  288. control={form.control}
  289. grantIndex={index}
  290. role={{ id: grant.roleId, label: grant.roleId }}
  291. grant={grant}
  292. onChange={(next) => updateGrant(grant.roleId, () => next)}
  293. />
  294. </div>
  295. ))}
  296. </div>
  297. )}
  298. </FormItemLayout>
  299. )}
  300. />
  301. </div>
  302. </ScrollArea>
  303. </Form>
  304. <SheetFooter className="mt-auto w-full border-t py-4">
  305. <Button type="default" onClick={confirmOnClose} disabled={isSubmitting}>
  306. Cancel
  307. </Button>
  308. <Button
  309. type="primary"
  310. onClick={form.handleSubmit(handleSaveRule)}
  311. loading={isSubmitting}
  312. >
  313. {mode === 'edit' ? 'Save rule' : 'Create rule'}
  314. </Button>
  315. </SheetFooter>
  316. </SheetContent>
  317. </Sheet>
  318. <DiscardChangesConfirmationDialog {...closeConfirmationModalProps} />
  319. </>
  320. )
  321. }