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