From cfa8a90b869a77e221f41a283ba623a7fe0820aa Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:26:27 +1100 Subject: [PATCH] migrate jit-prototype work to sec-462 changes --- .../JitDatabaseAccess/AddUserAccessModal.tsx | 326 --------- .../JitDbAccessConfiguration.tsx | 616 ++++++++++++------ .../JitDbAccessDeleteDialog.tsx | 57 ++ .../JitDbAccessMembersView.tsx | 251 ------- .../JitDbAccessRoleGrantFields.tsx | 230 +++++++ .../JitDbAccessRuleSheet.tsx | 183 ++++++ .../JitDbAccessRulesTable.tsx | 214 ++++++ .../ManageJitAccessPanel.tsx | 137 ---- .../JitDatabaseAccess/jitDbAccess.adapters.ts | 151 +++++ .../jitDbAccess.constants.ts | 12 + .../JitDatabaseAccess/jitDbAccess.types.ts | 50 ++ .../jitDbAccess.utils.test.ts | 99 +++ .../JitDatabaseAccess/jitDbAccess.utils.ts | 137 ++++ 13 files changed, 1535 insertions(+), 928 deletions(-) delete mode 100644 apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/AddUserAccessModal.tsx create mode 100644 apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessDeleteDialog.tsx delete mode 100644 apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessMembersView.tsx create mode 100644 apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRoleGrantFields.tsx create mode 100644 apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx create mode 100644 apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRulesTable.tsx delete mode 100644 apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/ManageJitAccessPanel.tsx create mode 100644 apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.adapters.ts create mode 100644 apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.constants.ts create mode 100644 apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.types.ts create mode 100644 apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.utils.test.ts create mode 100644 apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.utils.ts diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/AddUserAccessModal.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/AddUserAccessModal.tsx deleted file mode 100644 index 16be5de5f9d..00000000000 --- a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/AddUserAccessModal.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import { useParams } from 'common' -import { CalendarIcon, X } from 'lucide-react' -import { useState, useEffect } from 'react' -import { toast } from 'sonner' -import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -import { z } from 'zod' -import { Button, Modal } from 'ui' -import { Form_Shadcn_ } from 'ui' -import { - SelectContent_Shadcn_, - SelectGroup_Shadcn_, - SelectItem_Shadcn_, - SelectTrigger_Shadcn_, - SelectValue_Shadcn_, - Select_Shadcn_, -} from 'ui' - -import { DatePicker } from 'components/ui/DatePicker' -import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' -import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { format } from 'date-fns' -import dayjs from 'dayjs' - -const FormSchema = z.object({ - userId: z.string().min(1, 'Please select a user'), - roles: z - .array( - z.object({ - role: z.string(), - expires_at: z.date().optional(), - }) - ) - .min(1, 'Please select at least one role'), -}) - -interface AddUserAccessModalProps { - visible: boolean - onClose: () => void - onSubmit: (values: { - userId: string - userName: string - userEmail: string - roles: Array<{ role: string; expires_at?: Date }> - }) => void - isSubmitting?: boolean - editMode?: { - userId: string - userName: string - userEmail: string - currentRoles: Array<{ role: string; expires_at?: Date }> - } - usersWithAccess?: string[] // Array of user IDs who already have access -} - -const AddUserAccessModal = ({ - visible, - onClose, - onSubmit, - isSubmitting = false, - editMode, - usersWithAccess = [], -}: AddUserAccessModalProps) => { - const formId = 'add-user-access-form' - const { ref } = useParams() - const { data: project } = useSelectedProjectQuery() - const { data: organization } = useSelectedOrganizationQuery() - - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - userId: '', - roles: [], - }, - }) - - // Update form when editMode changes or modal opens - useEffect(() => { - if (visible) { - if (editMode) { - form.reset({ - userId: editMode.userId, - roles: editMode.currentRoles, - }) - } else { - form.reset({ - userId: '', - roles: [], - }) - } - } - }, [editMode, visible, form]) - - const { data: members, isLoading: isLoadingMembers } = useOrganizationMembersQuery({ - slug: organization?.slug, - }) - - // Filter out users who already have access (only in add mode) - const availableMembers = !editMode - ? members?.filter((m) => !usersWithAccess.includes(m.gotrue_id ?? m.primary_email)) - : members - - const { data: databaseRoles, isLoading: isLoadingRoles } = useDatabaseRolesQuery({ - projectRef: ref, - connectionString: project?.connectionString, - }) - - // Filter out system roles and roles that shouldn't be assigned - // and roles that can not login to the database - const assignableRoles = - databaseRoles?.filter( - (role) => - role.canLogin && - !role.is_superuser && - !role.name.startsWith('pg_') && - (!role.name.startsWith('supabase_') || role.name == 'supabase_read_only_user') && - !['pgbouncer', 'authenticator'].find((x) => x == role.name) - ) ?? [] - - const handleFormSubmit = async (values: z.infer) => { - const selectedMember = members?.find( - (m) => m.gotrue_id === values.userId || m.primary_email === values.userId - ) - - if (!selectedMember) { - toast.error('Selected user not found') - return - } - - onSubmit({ - userId: selectedMember.gotrue_id ?? values.userId, - userName: selectedMember.username ?? '', - userEmail: selectedMember.primary_email, - roles: values.roles, - }) - } - - const handleClose = () => { - form.reset() - onClose() - } - - const toggleRole = (roleName: string) => { - const currentRoles = form.getValues('roles') - const roleExists = currentRoles.some((r) => r.role === roleName) - - if (roleExists) { - // Remove role - const newRoles = currentRoles.filter((r) => r.role !== roleName) - form.setValue('roles', newRoles, { shouldValidate: true }) - } else { - // Add role with no expiry date - const newRoles = [...currentRoles, { role: roleName, expires_at: undefined }] - form.setValue('roles', newRoles, { shouldValidate: true }) - } - } - - const updateRoleExpiry = (roleName: string, expires_at: Date | undefined) => { - const currentRoles = form.getValues('roles') - const newRoles = currentRoles.map((r) => (r.role === roleName ? { ...r, expires_at } : r)) - form.setValue('roles', newRoles, { shouldValidate: true }) - } - - const selectedUserId = form.watch('userId') - const selectedRoles = form.watch('roles') - - return ( - - -
- <> - -

- {editMode - ? 'Update the roles assigned to this user. The expiry date applies to all roles.' - : 'Grant a user just-in-time access to your database by assigning them one or more PostgreSQL roles. Optionally set an expiry date for automatic access revocation.'} -

- - {/* User Selection */} -
- - {editMode ? ( -
-
- {editMode.userEmail} - {editMode.userName && ( - {editMode.userName} - )} -
-
- ) : ( - - form.setValue('userId', value, { shouldValidate: true }) - } - disabled={isLoadingMembers} - > - - - - - - {availableMembers?.map((member) => { - const userId = member.gotrue_id - return ( - -
- {member.primary_email} - {member.username && ( - - {member.username} - - )} -
-
- ) - })} -
-
-
- )} -
- - {/* Role Selection */} -
- -
- {isLoadingRoles ? ( -
Loading roles...
- ) : assignableRoles.length === 0 ? ( -
- No assignable roles found -
- ) : ( -
- {assignableRoles.map((role) => { - const roleData = selectedRoles.find((r) => r.role === role.name) - const isSelected = !!roleData - - return ( -
-
- toggleRole(role.name)} - className="rounded border-control mt-1" - /> -
-
{role.name}
- {isSelected && ( -
- { - updateRoleExpiry(role.name, to ? new Date(to) : undefined) - }} - hideClear={false} - > - {roleData?.expires_at - ? format(roleData.expires_at, 'PPp') - : 'Set expiry date'} - - {roleData?.expires_at && ( -
- )} -
-
-
- ) - })} -
- )} -
- {selectedRoles.length > 0 && ( -

- Selected {selectedRoles.length} role{selectedRoles.length !== 1 ? 's' : ''} -

- )} -
-
- - - - - - - - - -
-
- ) -} - -export default AddUserAccessModal diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx index c1a1d17f6e1..52421d27714 100644 --- a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx @@ -1,24 +1,23 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Loader2, Plus } from 'lucide-react' +import { Loader2 } from 'lucide-react' import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' +import AlertError from 'components/ui/AlertError' import { DocsButton } from 'components/ui/DocsButton' - -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { useJitDbAccessQuery } from 'data/jit-db-access/jit-db-access-query' -import { useJitDbAccessUpdateMutation } from 'data/jit-db-access/jit-db-access-update-mutation' +import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query' import { useJitDbAccessGrantMutation } from 'data/jit-db-access/jit-db-access-grant-mutation' -import { useJitDbAccessRevokeMutation } from 'data/jit-db-access/jit-db-access-revoke-mutation' import { useJitDbAccessMembersQuery } from 'data/jit-db-access/jit-db-access-members-query' +import { useJitDbAccessQuery } from 'data/jit-db-access/jit-db-access-query' +import { useJitDbAccessRevokeMutation } from 'data/jit-db-access/jit-db-access-revoke-mutation' +import { useJitDbAccessUpdateMutation } from 'data/jit-db-access/jit-db-access-update-mutation' +import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' +import { useProjectMembersQuery } from 'data/projects/project-members-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import AddUserAccessModal from './AddUserAccessModal' -import MembersView from './JitDbAccessMembersView' - import { DOCS_URL } from 'lib/constants' import { Alert, @@ -30,8 +29,6 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger, - Button, Card, CardContent, Switch, @@ -48,64 +45,57 @@ import { } from 'ui-patterns' import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' +import { JitDbAccessDeleteDialog } from './JitDbAccessDeleteDialog' +import { JitDbAccessRuleSheet } from './JitDbAccessRuleSheet' +import { JitDbAccessRulesTable } from './JitDbAccessRulesTable' +import { + getAssignableJitRoleOptions, + getJitMemberOptions, + mapJitMembersToUserRules, + serializeDraftRolesForGrantMutation, +} from './jitDbAccess.adapters' +import type { JitUserRule, SheetMode } from './jitDbAccess.types' +import { createDraft, draftFromRule } from './jitDbAccess.utils' + const JitDbAccessConfiguration = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() - const [isEnabled, setIsEnabled] = useState(false) - const [showAddUserModal, setShowAddUserModal] = useState(false) - const [editingUser, setEditingUser] = useState< - | { - userId: string - userName: string - userEmail: string - currentRoles: Array<{ role: string; expires_at?: Date }> - } - | undefined - >(undefined) + const { data: organization } = useSelectedOrganizationQuery() + + const [enabled, setEnabled] = useState(false) + const [sheetOpen, setSheetOpen] = useState(false) + const [sheetMode, setSheetMode] = useState('add') + const [editingUserId, setEditingUserId] = useState(null) + const [showInlineValidation, setShowInlineValidation] = useState(false) + const [showEnableJitDialog, setShowEnableJitDialog] = useState(false) + const [userPendingDelete, setUserPendingDelete] = useState(null) + const [draft, setDraft] = useState(() => createDraft([])) const { data: jitDbAccessConfiguration, - isLoading, - isSuccess, - } = useJitDbAccessQuery({ + error: jitDbAccessConfigurationError, + isError: isErrorJitDbAccessConfiguration, + isLoading: isLoadingConfiguration, + isSuccess: isSuccessConfiguration, + } = useJitDbAccessQuery({ projectRef: ref }) + + const { + data: jitMembers, + error: jitMembersError, + isError: isErrorJitMembers, + isLoading: isLoadingJitMembers, + } = useJitDbAccessMembersQuery({ projectRef: ref }) + + const { data: projectMembers, isLoading: isLoadingProjectMembers } = useProjectMembersQuery({ projectRef: ref, }) - const { data: jitMembers = [] } = useJitDbAccessMembersQuery({ + const { data: organizationMembers, isLoading: isLoadingOrganizationMembers } = + useOrganizationMembersQuery({ slug: organization?.slug }) + + const { data: databaseRoles, isLoading: isLoadingDatabaseRoles } = useDatabaseRolesQuery({ projectRef: ref, - }) - const { mutate: updateJitDbAccess, isLoading: isSubmitting } = useJitDbAccessUpdateMutation({ - onSuccess: () => { - toast.success('Successfully updated just-in-time (JIT) database access configuration') - }, - onError: (error) => { - setIsEnabled(initialIsEnabled) - toast.error( - `Failed to update just-in-time (JIT) database access enforcement: ${error.message}` - ) - }, - }) - - const { mutate: grantUserAccess, isLoading: isGranting } = useJitDbAccessGrantMutation({ - onSuccess: () => { - toast.success( - editingUser ? 'Successfully updated user access' : 'Successfully granted user access' - ) - setShowAddUserModal(false) - setEditingUser(undefined) - }, - onError: (error) => { - toast.error(`Failed to ${editingUser ? 'update' : 'grant'} user access: ${error.message}`) - }, - }) - - const { mutate: revokeUserAccess, isLoading: isRevoking } = useJitDbAccessRevokeMutation({ - onSuccess: () => { - toast.success('Successfully revoked user access') - }, - onError: (error) => { - toast.error(`Failed to revoke user access: ${error.message}`) - }, + connectionString: project?.connectionString, }) const { can: canUpdateJitDbAccess } = useAsyncCheckPermissions( @@ -117,211 +107,409 @@ const JitDbAccessConfiguration = () => { }, } ) - const initialIsEnabled = isSuccess - ? jitDbAccessConfiguration.appliedSuccessfully && jitDbAccessConfiguration.state == 'enabled' - : false + + const initialIsEnabled = + isSuccessConfiguration && + !!jitDbAccessConfiguration && + jitDbAccessConfiguration.appliedSuccessfully && + jitDbAccessConfiguration.state === 'enabled' const hasAccessToJitDbAccess = !( jitDbAccessConfiguration !== undefined && 'isUnavailable' in jitDbAccessConfiguration && jitDbAccessConfiguration.isUnavailable ) - const env = process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' ? 'prod' : 'staging' useEffect(() => { - if (!isLoading && jitDbAccessConfiguration) { - setIsEnabled(initialIsEnabled) + if (!isLoadingConfiguration && jitDbAccessConfiguration) { + setEnabled(initialIsEnabled) } - }, [isLoading]) + }, [initialIsEnabled, isLoadingConfiguration, jitDbAccessConfiguration]) + + const roleOptions = useMemo(() => getAssignableJitRoleOptions(databaseRoles), [databaseRoles]) + const roleIds = useMemo(() => roleOptions.map((role) => role.id), [roleOptions]) + + const users = useMemo( + () => mapJitMembersToUserRules(jitMembers, projectMembers, roleOptions), + [jitMembers, projectMembers, roleOptions] + ) + + const allMembers = useMemo( + () => getJitMemberOptions(organizationMembers, projectMembers), + [organizationMembers, projectMembers] + ) + + const editingUser = useMemo( + () => users.find((user) => user.id === editingUserId) ?? null, + [users, editingUserId] + ) + + const membersWithRules = useMemo(() => new Set(users.map((user) => user.memberId)), [users]) + + const availableMembersForAdd = useMemo( + () => allMembers.filter((member) => !membersWithRules.has(member.id)), + [allMembers, membersWithRules] + ) + + const memberOptionsForSheet = useMemo(() => { + if (sheetMode !== 'edit') return availableMembersForAdd + if (!editingUser) return allMembers + + if (allMembers.some((member) => member.id === editingUser.memberId)) return allMembers + + return [ + { + id: editingUser.memberId, + email: editingUser.email, + name: editingUser.name, + }, + ...allMembers, + ] + }, [sheetMode, availableMembersForAdd, allMembers, editingUser]) + + const isDuplicateSelectedMember = + sheetMode === 'add' && draft.memberId !== '' && membersWithRules.has(draft.memberId) + + const enabledRoleCount = useMemo( + () => draft.grants.filter((grant) => grant.enabled).length, + [draft.grants] + ) + + const activeRuleCount = useMemo( + () => users.filter((user) => user.status.active > 0).length, + [users] + ) + + const inlineValidation = useMemo( + () => ({ + member: !draft.memberId + ? 'Select a member for this JIT access rule.' + : isDuplicateSelectedMember + ? 'This member already has a JIT access rule. Edit their existing rule from the list.' + : undefined, + roles: enabledRoleCount > 0 ? undefined : 'Select at least one role.', + }), + [draft.memberId, enabledRoleCount, isDuplicateSelectedMember] + ) + + const resetSheetState = () => { + setSheetOpen(false) + setEditingUserId(null) + setShowInlineValidation(false) + } + + const closeSheet = () => { + resetSheetState() + } + + const submitJitToggle = (nextEnabled: boolean) => { + if (!ref) { + console.error('Project ref is required') + return + } + + setEnabled(nextEnabled) - const toggleJitDbAccess = async () => { - if (!ref) return console.error('Project ref is required') - setIsEnabled(!isEnabled) updateJitDbAccess({ projectRef: ref, - requestedConfig: { state: !isEnabled ? 'enabled' : 'disabled' }, + requestedConfig: { state: nextEnabled ? 'enabled' : 'disabled' }, }) } - const handleGrantAccess = (values: { - userId: string - userName: string - userEmail: string - roles: Array<{ role: string; expires_at?: Date }> - }) => { - if (!ref) return console.error('Project ref is required') + const { mutate: updateJitDbAccess, isPending: isUpdatingJitDbAccess } = + useJitDbAccessUpdateMutation({ + onSuccess: (_, variables) => { + const nextEnabled = variables.requestedConfig.state === 'enabled' - // Convert Date objects to Unix timestamps (seconds) for each role - const rolesWithTimestamps = values.roles.map((r) => ({ - role: r.role, - expires_at: r.expires_at ? Math.floor(r.expires_at.getTime() / 1000) : undefined, - })) + if (nextEnabled) { + toast.success('JIT access enabled') + } else { + toast.success( + activeRuleCount > 0 + ? `JIT access disabled. ${activeRuleCount} configured member${activeRuleCount === 1 ? '' : 's'} can no longer request temporary database access.` + : 'JIT access disabled.' + ) + } + }, + onError: (error) => { + setEnabled(initialIsEnabled) + toast.error(`Failed to update just-in-time (JIT) database access: ${error.message}`) + }, + }) + + const { mutate: grantUserAccess, isPending: isGrantingAccess } = useJitDbAccessGrantMutation({ + onSuccess: () => { + toast.success( + sheetMode === 'edit' + ? 'Successfully updated user access' + : 'Successfully granted user access' + ) + resetSheetState() + }, + onError: (error) => { + toast.error( + `Failed to ${sheetMode === 'edit' ? 'update' : 'grant'} user access: ${error.message}` + ) + }, + }) + + const { mutate: revokeUserAccess, isPending: isRevokingAccess } = useJitDbAccessRevokeMutation({ + onSuccess: (_, variables) => { + toast.success('Successfully revoked user access') + setUserPendingDelete(null) + + if (editingUserId === variables.userId) { + resetSheetState() + } + }, + onError: (error) => { + toast.error(`Failed to revoke user access: ${error.message}`) + }, + }) + + const isMutating = isUpdatingJitDbAccess || isGrantingAccess || isRevokingAccess + const disableRuleActions = isMutating || isLoadingDatabaseRoles || isLoadingOrganizationMembers + const isRulesLoading = isLoadingJitMembers || isLoadingProjectMembers + + const handleJitToggleChange = (checked: boolean) => { + if (!hasAccessToJitDbAccess || !canUpdateJitDbAccess) return + + if (checked && !enabled) { + if (activeRuleCount > 0) { + setShowEnableJitDialog(true) + return + } + + submitJitToggle(true) + return + } + + if (!checked && enabled) { + submitJitToggle(false) + } + } + + const handleConfirmEnableJit = () => { + setShowEnableJitDialog(false) + submitJitToggle(true) + } + + const openAddRuleSheet = () => { + if (!canUpdateJitDbAccess) return + + setSheetMode('add') + setEditingUserId(null) + setDraft(createDraft(roleIds)) + setShowInlineValidation(false) + setSheetOpen(true) + } + + const openEditRuleSheet = (user: JitUserRule) => { + if (!canUpdateJitDbAccess) return + + setSheetMode('edit') + setEditingUserId(user.id) + setDraft(draftFromRule(user, roleIds)) + setShowInlineValidation(false) + setSheetOpen(true) + } + + const openDeleteDialog = (user: JitUserRule) => { + if (!canUpdateJitDbAccess) return + setUserPendingDelete(user) + } + + const handleSaveRule = () => { + setShowInlineValidation(true) + if (inlineValidation.member || inlineValidation.roles) return + + if (!ref) { + console.error('Project ref is required') + return + } + + const roles = serializeDraftRolesForGrantMutation(draft) + if (roles.length === 0) return grantUserAccess({ projectRef: ref, - userId: values.userId, - roles: rolesWithTimestamps, + userId: draft.memberId, + roles, }) } - const handleRevokeAccess = (userId: string, userEmail: string) => { - if (!ref) return console.error('Project ref is required') + const handleConfirmDelete = () => { + if (!ref || !userPendingDelete) { + if (!ref) console.error('Project ref is required') + return + } revokeUserAccess({ projectRef: ref, - userId, + userId: userPendingDelete.memberId, }) } + const switchDisabled = + isLoadingConfiguration || + isUpdatingJitDbAccess || + !canUpdateJitDbAccess || + !hasAccessToJitDbAccess + + const switchTooltipText = !canUpdateJitDbAccess + ? 'You need additional permissions to update JIT database access for your project.' + : !hasAccessToJitDbAccess + ? 'Your project does not have access to JIT database access. Please update to the latest Postgres version.' + : undefined + return ( <> - Just-in-time (JIT) database access + Just-in-Time (JIT) - + + + {isErrorJitDbAccessConfiguration && ( + + )} + + + + +
+ {(isLoadingConfiguration || isUpdatingJitDbAccess) && ( + + )} + + + +
+ +
+
+ {switchTooltipText && ( + {switchTooltipText} + )} +
+
+
+ + {isSuccessConfiguration && !jitDbAccessConfiguration?.appliedSuccessfully && ( + + Please try updating again, or contact support if this error persists + + )} +
+
+ {!hasAccessToJitDbAccess && ( - - Your project does not have access to just-in-time database access. This feature - requires Postgres version 17 or later. Please{' '} + + This feature requires Postgres version 17 or later. Please{' '} upgrade your database {' '} - to the latest Postgres version to enable this feature. + to the latest Postgres version to enable JIT access. )} - {hasAccessToJitDbAccess && ( - - - - - -
- {(isLoading || isSubmitting) && ( - - )} - {isSuccess && ( - - - {/* [Joshen] Added div as tooltip is messing with data state property of toggle */} -
- -
-
- {(!canUpdateJitDbAccess || !hasAccessToJitDbAccess) && ( - - {!canUpdateJitDbAccess - ? 'You need additional permissions to update Just-in-time database access for your project' - : !hasAccessToJitDbAccess - ? 'Your project does not have access to Just-in-time database access. Please update to the latest Postgres version' - : undefined} - - )} -
- )} -
-
- - - - {jitDbAccessConfiguration?.state !== 'enabled' - ? 'Access must be configured' - : 'Users will lose access'} - - - {jitDbAccessConfiguration?.state !== 'enabled' - ? 'Access via JIT must be granted on a per user basis. Any existing mappings will immediately become active when JIT access is enabled. Confirm to proceed now?' - : 'Users with JIT access will no longer be able to connect. The user to role mappings will not be removed and access will automatically be restored if JIT access is re-enabled. Confirm to proceed now?'} - - - - Cancel - - {jitDbAccessConfiguration?.state !== 'enabled' - ? 'Enable JIT Access' - : 'Disable JIT Access'} - - - -
-
- {isSuccess && !jitDbAccessConfiguration?.appliedSuccessfully && ( - - Please try updating again, or contact support if this error persists - - )} -
-
- )} - {isEnabled && ( -
-
-
-

User Access

-

- Manage which users have JIT database access -

-
- -
- { - setEditingUser(user) - setShowAddUserModal(true) - }} - onRevokeAccess={handleRevokeAccess} + + {enabled && hasAccessToJitDbAccess && ( + <> + {isErrorJitMembers && ( + + )} + + -
+ )}
- { - setShowAddUserModal(false) - setEditingUser(undefined) + { + if (editingUser) openDeleteDialog(editingUser) }} - onSubmit={handleGrantAccess} - isSubmitting={isGranting} - editMode={editingUser} - usersWithAccess={jitMembers.map((m) => m.user_id)} /> + + { + if (!open) setUserPendingDelete(null) + }} + onConfirm={handleConfirmDelete} + /> + + + + + JIT access will activate existing rules + +
+

+ Enabling JIT will allow {activeRuleCount} configured member + {activeRuleCount === 1 ? '' : 's'} to request temporary database access + immediately. +

+
+
+
+ + Cancel + + Enable JIT access + + +
+
) } diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessDeleteDialog.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessDeleteDialog.tsx new file mode 100644 index 00000000000..4831707a503 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessDeleteDialog.tsx @@ -0,0 +1,57 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from 'ui' + +import type { JitUserRule } from './jitDbAccess.types' + +interface JitDbAccessDeleteDialogProps { + user: JitUserRule | null + open: boolean + isDeleting?: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void +} + +export function JitDbAccessDeleteDialog({ + user, + open, + isDeleting = false, + onOpenChange, + onConfirm, +}: JitDbAccessDeleteDialogProps) { + const userDisplayName = user?.name ?? user?.email ?? 'this user' + + return ( + + + + Delete JIT access rule + +
+

+ Remove the JIT access rule for {userDisplayName}? +

+

+ This revokes any assigned database roles for this member and removes their JIT + access configuration. +

+
+
+
+ + Cancel + + Delete rule + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessMembersView.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessMembersView.tsx deleted file mode 100644 index 756d86269a6..00000000000 --- a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessMembersView.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { AlertCircle, HelpCircle, Trash2, Pencil } from 'lucide-react' -import { format } from 'date-fns' -import { useState } from 'react' - -import { useParams } from 'common' -import AlertError from 'components/ui/AlertError' -import { useJitDbAccessMembersQuery } from 'data/jit-db-access/jit-db-access-members-query' -import { useProjectMembersQuery } from 'data/projects/project-members-query' -import { useProfile } from 'lib/profile' -import { partition } from 'lodash' -import { useMemo } from 'react' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - Button, - Loading, - Tooltip, - TooltipContent, - TooltipTrigger, - Table, - TableHeader, - TableHead, - TableBody, - TableCell, - TableRow, - Card, - CardContent, - Badge, - Skeleton, -} from 'ui' -import { Admonition } from 'ui-patterns' -//import { MemberRow } from './MemberRow' - -export interface MembersViewProps { - onEditUser?: (user: { - userId: string - userName: string - userEmail: string - currentRoles: Array<{ role: string; expires_at?: Date }> - }) => void - onRevokeAccess?: (userId: string, userEmail: string) => void -} - -const MembersView = ({ onEditUser, onRevokeAccess }: MembersViewProps) => { - const { ref } = useParams() - const { profile } = useProfile() - const [userToRevoke, setUserToRevoke] = useState<{ userId: string; userEmail: string } | null>( - null - ) - - const { - data: members = [], - error: membersError, - isLoading: isLoadingMembers, - isError: isErrorMembers, - isSuccess: isSuccessMembers, - } = useJitDbAccessMembersQuery({ projectRef: ref }) - - const { - data: projectMembers = [], - error: projectMembersError, - isLoading: isLoadingProjectMembers, - isError: isErrorProjectMembers, - isSuccess: isSuccessProjectMembers, - } = useProjectMembersQuery({ projectRef: ref }) - - const memberMap = new Map(projectMembers.map((m) => [m.user_id, m])) - - const decoratedMembers = members.map((item) => { - const member = memberMap.get(item.user_id) - if (member) { - return { - ...item, - primary_email: member.primary_email, - username: member.username, - } - } - return item // if no match, leave it as is - }) - - return ( - <> - {isLoadingMembers && ( - - - - - - - )} - - {isErrorMembers && ( - - )} - - {isSuccessMembers && ( - <> - {decoratedMembers.length === 0 ? ( - - -
- -

No users have JIT database access

-

- Click "Add user" to grant access -

-
-
-
- ) : ( -
- - - - - - User - Roles - Expiry - Actions - - - - - {decoratedMembers.map((member) => ( - - -
- - {member.primary_email || member.user_id} - - {member.username && ( - - {member.username} - - )} -
-
- -
- {member.user_roles?.map((roleObj, idx) => ( - - {roleObj.role} - - ))} -
-
- -
- {member.user_roles?.map((roleObj, idx) => ( - - {roleObj.role}:{' '} - {roleObj.expires_at - ? format(new Date(roleObj.expires_at * 1000), 'PPPp') - : 'Never'} - - ))} -
-
- -
- - -
-
-
- ))} -
-
-
-
-
- )} - - )} - - setUserToRevoke(null)}> - - - Revoke database access - - Are you sure you want to revoke database access for{' '} - {userToRevoke?.userEmail}? This will remove all - assigned roles and they will no longer be able to connect to the database. - - - - setUserToRevoke(null)}>Cancel - { - if (userToRevoke) { - onRevokeAccess?.(userToRevoke.userId, userToRevoke.userEmail) - setUserToRevoke(null) - } - }} - > - Revoke access - - - - - - ) -} - -export default MembersView diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRoleGrantFields.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRoleGrantFields.tsx new file mode 100644 index 00000000000..bfd8ff4f171 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRoleGrantFields.tsx @@ -0,0 +1,230 @@ +import dayjs from 'dayjs' + +import { DatePicker } from 'components/ui/DatePicker' +import { InlineLink } from 'components/ui/InlineLink' +import { DOCS_URL } from 'lib/constants' +import { + Checkbox_Shadcn_, + Input_Shadcn_, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + WarningIcon, +} from 'ui' +import { Admonition } from 'ui-patterns/admonition' + +import { JIT_EXPIRY_MODE_OPTIONS, JIT_MAX_CUSTOM_EXPIRY_YEARS } from './jitDbAccess.constants' +import type { JitRoleGrantDraft, JitRoleOption } from './jitDbAccess.types' +import { getRelativeDatetimeByMode } from './jitDbAccess.utils' + +interface JitDbAccessRoleGrantFieldsProps { + role: JitRoleOption + grant: JitRoleGrantDraft + onChange: (next: JitRoleGrantDraft) => void +} + +export function JitDbAccessRoleGrantFields({ + role, + grant, + onChange, +}: JitDbAccessRoleGrantFieldsProps) { + const isSuperuserRole = role.id === 'postgres' + const isReadOnlyRole = role.id === 'supabase_read_only_user' + const checkboxId = `jit-role-${role.id}` + + return ( +
+ + + {grant.enabled && ( +
+
+
+ {isSuperuserRole && ( + + The selected role has unrestricted access and bypasses row-level security. + Consider using a{' '} + + custom Postgres role + {' '} + with only the permissions required. + + } + /> + )} + + {isReadOnlyRole && ( + + The selected role has read-only access to all schemas. Consider using a{' '} + + custom Postgres role + {' '} + with only the permissions required. + + } + className="rounded-md" + /> + )} + +
+

Expires in

+
+
+ { + const nextMode = value as JitRoleGrantDraft['expiryMode'] + + if (nextMode === 'never') { + onChange({ + ...grant, + expiryMode: nextMode, + hasExpiry: false, + expiry: '', + }) + return + } + + if (nextMode === 'custom') { + onChange({ + ...grant, + expiryMode: nextMode, + hasExpiry: true, + expiry: grant.expiry || getRelativeDatetimeByMode('1h'), + }) + return + } + + onChange({ + ...grant, + expiryMode: nextMode, + hasExpiry: true, + expiry: getRelativeDatetimeByMode(nextMode), + }) + }} + > + + + + + {JIT_EXPIRY_MODE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
+ + {grant.expiryMode === 'custom' && ( + { + const selectedDate = value.to || value.from || '' + onChange({ + ...grant, + hasExpiry: true, + expiry: selectedDate, + }) + }} + triggerButtonClassName="min-w-[120px]" + > + {grant.expiry ? dayjs(grant.expiry).format('DD MMM, HH:mm') : 'Select date'} + + )} +
+ + {grant.expiryMode === 'never' && ( +
+ + + No expiry means ongoing database access until manually revoked. + +
+ )} +
+ +
+

+ Restricted IP addresses{' '} + (optional) +

+ + onChange({ + ...grant, + hasIpRestriction: event.target.value.trim().length > 0, + ipRanges: event.target.value, + }) + } + placeholder="e.g. 192.168.0.0/24, 203.0.113.4/32" + /> +

Comma-separated CIDR ranges

+
+
+
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx new file mode 100644 index 00000000000..365b381d891 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx @@ -0,0 +1,183 @@ +import { DOCS_URL } from 'lib/constants' + +import { InlineLink } from 'components/ui/InlineLink' +import { + Button, + ScrollArea, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +import { JitDbAccessRoleGrantFields } from './JitDbAccessRoleGrantFields' +import type { JitMemberOption, JitUserRule, JitUserRuleDraft, SheetMode } from './jitDbAccess.types' + +interface JitDbAccessRuleSheetProps { + open: boolean + mode: SheetMode + draft: JitUserRuleDraft + editingUser: JitUserRule | null + memberOptions: JitMemberOption[] + availableMembersForAddCount: number + showInlineValidation: boolean + inlineValidation: { + member?: string + roles?: string + } + isSubmitting?: boolean + onDraftChange: (next: JitUserRuleDraft) => void + onCancel: () => void + onSave: () => void + onRequestDelete: () => void +} + +export function JitDbAccessRuleSheet({ + open, + mode, + draft, + editingUser, + memberOptions, + availableMembersForAddCount, + showInlineValidation, + inlineValidation, + isSubmitting = false, + onDraftChange, + onCancel, + onSave, + onRequestDelete, +}: JitDbAccessRuleSheetProps) { + const updateGrant = ( + roleId: string, + updater: (grant: JitUserRuleDraft['grants'][number]) => JitUserRuleDraft['grants'][number] + ) => { + onDraftChange({ + ...draft, + grants: draft.grants.map((grant) => (grant.roleId === roleId ? updater(grant) : grant)), + }) + } + + return ( + !nextOpen && onCancel()}> + + + + {mode === 'edit' ? 'Edit JIT access rule' : 'New JIT access rule'} + + + Configure which database roles a user can request with JIT access. + + + + +
+ + onDraftChange({ ...draft, memberId: value })} + > + + + + + {memberOptions.map((member) => ( + + {member.name ? ( + <> + {member.name}{' '} + ({member.email}) + + ) : ( + member.email + )} + + ))} + + + + {mode === 'edit' && ( +

+ The member cannot be changed when editing an existing JIT access rule. +

+ )} + + {mode === 'add' && availableMembersForAddCount === 0 && ( +

+ All project members already have JIT access rules. Edit an existing rule from the + table above. +

+ )} + + {showInlineValidation && inlineValidation.member && ( +

{inlineValidation.member}

+ )} +
+ + +
+ {draft.grants.length === 0 ? ( +
No assignable roles found
+ ) : ( + draft.grants.map((grant, index) => ( +
0 ? 'border-t' : ''}> + updateGrant(grant.roleId, () => next)} + /> +
+ )) + )} +
+ +

+ Define scoped permissions with{' '} + + custom Postgres roles + {' '} + before creating a JIT access rule. Narrow roles limit the impact of direct database + access. +

+ + {showInlineValidation && inlineValidation.roles && ( +

{inlineValidation.roles}

+ )} +
+
+
+ + + {mode === 'edit' && editingUser && ( +
+ +
+ )} + + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRulesTable.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRulesTable.tsx new file mode 100644 index 00000000000..3ebc0b244dc --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRulesTable.tsx @@ -0,0 +1,214 @@ +import { EllipsisVertical, Pencil, Plus, Trash2 } from 'lucide-react' + +import { + Badge, + Button, + Card, + CardContent, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' + +import type { JitUserRule } from './jitDbAccess.types' +import { getJitStatusDisplay } from './jitDbAccess.utils' + +interface JitDbAccessRulesTableProps { + users: JitUserRule[] + isLoading?: boolean + canUpdate: boolean + disableActions?: boolean + onAddRule: () => void + onEditRule: (user: JitUserRule) => void + onDeleteRule: (user: JitUserRule) => void +} + +export function JitDbAccessRulesTable({ + users, + isLoading = false, + canUpdate, + disableActions = false, + onAddRule, + onEditRule, + onDeleteRule, +}: JitDbAccessRulesTableProps) { + const addDisabled = disableActions || !canUpdate + + if (isLoading) { + return ( + + +
+
+ + +
+ +
+ +
+
+ ) + } + + return ( + + +
+
+

JIT access rules

+

+ Configure which members can request temporary database access. +

+
+ + + +
+ +
+
+ {!canUpdate && ( + + You need additional permissions to manage JIT access rules. + + )} +
+
+ + + + + Member + Roles + Status + + Actions + + + + + {users.length === 0 ? ( + + +

No JIT access rules

+

+ Add your first JIT access rule above +

+
+
+ ) : ( + users.map((user) => { + const statusDisplay = getJitStatusDisplay(user.status) + const enabledGrants = user.grants.filter((grant) => grant.enabled) + const rowIsInteractive = canUpdate && !disableActions + + return ( + { + if ((event.target as HTMLElement).closest('button')) return + onEditRule(user) + } + : undefined + } + onKeyDown={ + rowIsInteractive + ? (event) => { + if ((event.target as HTMLElement).closest('button')) return + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onEditRule(user) + } + } + : undefined + } + tabIndex={rowIsInteractive ? 0 : undefined} + > + + {user.name &&

{user.name}

} +

{user.email}

+
+ + {enabledGrants.length} role{enabledGrants.length === 1 ? '' : 's'} + + + {statusDisplay.badges.length > 0 ? ( + + {statusDisplay.badges.map((badge) => ( + + {badge.label} + + ))} + + ) : null} + + + + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/ManageJitAccessPanel.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/ManageJitAccessPanel.tsx deleted file mode 100644 index 5060cb094c5..00000000000 --- a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/ManageJitAccessPanel.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import { SubmitHandler, useForm } from 'react-hook-form' -import { toast } from 'sonner' -import z from 'zod' - -import { FormActions } from 'components/ui/Forms/FormActions' -import { useDatabaseRoleCreateMutation } from 'data/database-roles/database-role-create-mutation' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { - FormControl_Shadcn_, - FormField_Shadcn_, - FormItem_Shadcn_, - FormLabel_Shadcn_, - FormMessage_Shadcn_, - Form_Shadcn_, - Input_Shadcn_, - SidePanel, - Switch, -} from 'ui' - -interface ManageJitAccessPanelProps { - visible: boolean - onClose: () => void -} - -const FormSchema = z.object({ - name: z.string().trim().min(1, 'You must provide a name').default(''), - isSuperuser: z.boolean().default(false), - canLogin: z.boolean().default(false), - canCreateRole: z.boolean().default(false), - canCreateDb: z.boolean().default(false), - isReplicationRole: z.boolean().default(false), - canBypassRls: z.boolean().default(false), -}) - -const initialValues = { - name: '', - isSuperuser: false, - canLogin: false, - canCreateRole: false, - canCreateDb: false, - isReplicationRole: false, - canBypassRls: false, -} - -export const ManageJitAccessPanel = ({ visible, onClose }: ManageJitAccessPanelProps) => { - const formId = 'add-jit-access' - - const { data: project } = useSelectedProjectQuery() - - const form = useForm>({ - resolver: zodResolver(FormSchema), - }) - - const { mutate: createDatabaseRole, isLoading: isCreating } = useDatabaseRoleCreateMutation({ - onSuccess: (_, vars) => { - toast.success(`Successfully updated user's access: ${vars.payload.name}`) - handleClose() - }, - }) - - const onSubmit: SubmitHandler> = async (values) => { - if (!project) return console.error('Project is required') - // createDatabaseRole({ - // projectRef: project.ref, - // connectionString: project.connectionString, - // payload: values, - // }) - } - - const handleClose = () => { - onClose() - form.reset(initialValues) - } - - return ( - - -
- } - > - -
- ( - - - Name - - - - - - - )} - /> -
-
- - Role privileges - -
-
- - - - -
-

These privileges cannot be granted via the Dashboard:

- -
-
-
- -
- - ) -} diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.adapters.ts b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.adapters.ts new file mode 100644 index 00000000000..e51e6c22869 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.adapters.ts @@ -0,0 +1,151 @@ +import { type DatabaseRolesData, type PgRole } from 'data/database-roles/database-roles-query' +import type { JitDbAccessMembersData } from 'data/jit-db-access/jit-db-access-members-query' +import type { OrganizationMembersData } from 'data/organizations/organization-members-query' +import type { ProjectMembersData } from 'data/projects/project-members-query' + +import type { + JitMemberOption, + JitRoleGrantDraft, + JitRoleOption, + JitUserRule, + JitUserRuleDraft, +} from './jitDbAccess.types' +import { + cloneGrants, + computeStatusFromGrants, + createEmptyGrant, + mergeRoleIds, + toUnixSeconds, +} from './jitDbAccess.utils' + +function isAssignableJitRole(role: PgRole) { + return ( + role.canLogin && + !role.isSuperuser && + !role.name.startsWith('pg_') && + (!role.name.startsWith('supabase_') || role.name === 'supabase_read_only_user') && + !['pgbouncer', 'authenticator'].includes(role.name) + ) +} + +function serializeAllowedNetworks(roleObj: { + allowed_networks?: { + allowed_cidrs?: Array<{ cidr: string }> + allowed_cidrs_v6?: Array<{ cidr: string }> + } +}) { + const cidrs = roleObj.allowed_networks?.allowed_cidrs?.map((item) => item.cidr) ?? [] + const cidrsV6 = roleObj.allowed_networks?.allowed_cidrs_v6?.map((item) => item.cidr) ?? [] + return [...cidrs, ...cidrsV6] +} + +export function getAssignableJitRoleOptions( + databaseRoles?: DatabaseRolesData | null +): JitRoleOption[] { + return ( + databaseRoles + ?.filter(isAssignableJitRole) + .map((role) => ({ id: role.name, label: role.name })) + .sort((a, b) => a.label.localeCompare(b.label)) ?? [] + ) +} + +export function getJitMemberOptions( + organizationMembers?: OrganizationMembersData | null, + projectMembers?: ProjectMembersData | null +): JitMemberOption[] { + const byId = new Map() + + for (const member of organizationMembers ?? []) { + const id = member.gotrue_id ?? member.primary_email + if (!id) continue + + byId.set(id, { + id, + email: member.primary_email ?? id, + name: member.username || undefined, + }) + } + + for (const member of projectMembers ?? []) { + const id = member.user_id ?? member.primary_email + if (!id) continue + + byId.set(id, { + id, + email: member.primary_email ?? byId.get(id)?.email ?? id, + name: member.username ?? byId.get(id)?.name, + }) + } + + return Array.from(byId.values()).sort((a, b) => a.email.localeCompare(b.email)) +} + +export function mapJitMembersToUserRules( + jitMembers: JitDbAccessMembersData | undefined, + projectMembers: ProjectMembersData | undefined, + roleOptions: JitRoleOption[] +): JitUserRule[] { + const memberMap = new Map((projectMembers ?? []).map((member) => [member.user_id, member])) + const baseRoleIds = roleOptions.map((role) => role.id) + + return (jitMembers ?? []).map((item) => { + const mappedMember = memberMap.get(item.user_id) + const assignedRoles: JitRoleGrantDraft[] = (item.user_roles ?? []).map((roleObj) => { + const expiresAt = typeof roleObj.expires_at === 'number' ? roleObj.expires_at : undefined + const hasExpiry = typeof expiresAt === 'number' + const allowedNetworks = serializeAllowedNetworks(roleObj) + return { + ...createEmptyGrant(roleObj.role), + roleId: roleObj.role, + enabled: true, + hasExpiry, + expiryMode: hasExpiry ? 'custom' : 'never', + expiry: hasExpiry ? new Date(expiresAt * 1000).toISOString() : '', + hasIpRestriction: allowedNetworks.length > 0, + ipRanges: allowedNetworks.join(', '), + } + }) + + const assignedByRoleId = new Map(assignedRoles.map((grant) => [grant.roleId, grant])) + const allRoleIds = mergeRoleIds( + baseRoleIds, + assignedRoles.map((grant) => grant.roleId) + ) + const grants = allRoleIds.map((roleId) => ({ + ...createEmptyGrant(roleId), + ...(assignedByRoleId.get(roleId) ?? {}), + roleId, + })) + + const email = mappedMember?.primary_email ?? item.user_id + const name = mappedMember?.username ?? undefined + + return { + id: item.user_id, + memberId: item.user_id, + email, + name, + grants: cloneGrants(grants), + status: computeStatusFromGrants(grants), + } + }) +} + +export function serializeDraftRolesForGrantMutation(draft: JitUserRuleDraft) { + return draft.grants + .filter((grant) => grant.enabled) + .map((grant) => { + const expires_at = grant.hasExpiry ? toUnixSeconds(grant.expiry) : undefined + + // TODO(DEPR-366): IP restrictions are prototype-only in the UI for now and are not sent to + // the SEC-462 backend payload until API support is available. + void grant.hasIpRestriction + void grant.ipRanges + + return { + role: grant.roleId, + ...(typeof expires_at === 'number' ? { expires_at } : {}), + } + }) +} diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.constants.ts b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.constants.ts new file mode 100644 index 00000000000..dd085e4b705 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.constants.ts @@ -0,0 +1,12 @@ +import type { JitExpiryMode } from './jitDbAccess.types' + +export const JIT_EXPIRY_MODE_OPTIONS: Array<{ value: JitExpiryMode; label: string }> = [ + { value: '1h', label: '1 hour' }, + { value: '1d', label: '1 day' }, + { value: '7d', label: '7 days' }, + { value: '30d', label: '30 days' }, + { value: 'custom', label: 'Custom' }, + { value: 'never', label: 'Never' }, +] + +export const JIT_MAX_CUSTOM_EXPIRY_YEARS = 1 diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.types.ts b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.types.ts new file mode 100644 index 00000000000..8a098eff13e --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.types.ts @@ -0,0 +1,50 @@ +export type JitExpiryMode = '1h' | '1d' | '7d' | '30d' | 'custom' | 'never' + +export type JitStatus = { + active: number + expired: number + activeIp: number + expiredIp: number +} + +export type JitStatusBadge = { + label: string + variant: 'default' | 'success' | 'warning' +} + +export type JitMemberOption = { + id: string + email: string + name?: string +} + +export type JitRoleOption = { + id: string + label: string +} + +export type JitRoleGrantDraft = { + roleId: string + enabled: boolean + expiryMode: JitExpiryMode + hasExpiry: boolean + expiry: string + hasIpRestriction: boolean + ipRanges: string +} + +export type JitUserRuleDraft = { + memberId: string + grants: JitRoleGrantDraft[] +} + +export type JitUserRule = { + id: string + memberId: string + email: string + name?: string + grants: JitRoleGrantDraft[] + status: JitStatus +} + +export type SheetMode = 'add' | 'edit' diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.utils.test.ts b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.utils.test.ts new file mode 100644 index 00000000000..9ca44a60de4 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.utils.test.ts @@ -0,0 +1,99 @@ +import dayjs from 'dayjs' +import { describe, expect, it } from 'vitest' + +import { serializeDraftRolesForGrantMutation } from './jitDbAccess.adapters' +import type { JitUserRuleDraft } from './jitDbAccess.types' +import { + computeStatusFromGrants, + createEmptyGrant, + getRelativeDatetimeByMode, +} from './jitDbAccess.utils' + +describe('jitDbAccess.utils', () => { + it('returns empty expiry string for never/custom-only fallback modes', () => { + expect(getRelativeDatetimeByMode('never')).toBe('') + expect(getRelativeDatetimeByMode('custom')).toBe('') + }) + + it('creates future datetimes for preset expiry modes', () => { + const inOneHour = dayjs(getRelativeDatetimeByMode('1h')) + expect(inOneHour.isValid()).toBe(true) + expect(inOneHour.isAfter(dayjs())).toBe(true) + }) + + it('computes active and expired status counts including IP counts', () => { + const activeGrant = { + ...createEmptyGrant('role_active'), + enabled: true, + hasExpiry: true, + expiry: dayjs().add(1, 'day').toISOString(), + hasIpRestriction: true, + ipRanges: '192.0.2.0/24', + } + + const expiredGrant = { + ...createEmptyGrant('role_expired'), + enabled: true, + hasExpiry: true, + expiry: dayjs().subtract(1, 'day').toISOString(), + hasIpRestriction: true, + ipRanges: '203.0.113.0/24', + } + + const perpetualGrant = { + ...createEmptyGrant('role_never'), + enabled: true, + hasExpiry: false, + expiryMode: 'never' as const, + expiry: '', + } + + expect(computeStatusFromGrants([activeGrant, expiredGrant, perpetualGrant])).toEqual({ + active: 2, + expired: 1, + activeIp: 1, + expiredIp: 1, + }) + }) +}) + +describe('serializeDraftRolesForGrantMutation', () => { + it('serializes supported role fields and omits prototype-only IP fields', () => { + const expiry = '2026-06-01T12:00:00.000Z' + const draft: JitUserRuleDraft = { + memberId: 'user-1', + grants: [ + { + ...createEmptyGrant('postgres'), + enabled: true, + hasExpiry: true, + expiryMode: 'custom', + expiry, + hasIpRestriction: true, + ipRanges: '192.0.2.0/24', + }, + { + ...createEmptyGrant('supabase_read_only_user'), + enabled: true, + hasExpiry: false, + expiryMode: 'never', + expiry: '', + }, + { + ...createEmptyGrant('ignored_disabled'), + enabled: false, + }, + ], + } + + expect(serializeDraftRolesForGrantMutation(draft)).toEqual([ + { + role: 'postgres', + expires_at: dayjs(expiry).unix(), + }, + { + role: 'supabase_read_only_user', + }, + ]) + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.utils.ts b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.utils.ts new file mode 100644 index 00000000000..2a1a208d045 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/jitDbAccess.utils.ts @@ -0,0 +1,137 @@ +import dayjs from 'dayjs' + +import type { + JitExpiryMode, + JitRoleGrantDraft, + JitStatus, + JitStatusBadge, + JitUserRule, + JitUserRuleDraft, +} from './jitDbAccess.types' + +export function getRelativeDatetimeByMode(mode: JitExpiryMode) { + if (mode === '1h') return dayjs().add(1, 'hour').toISOString() + if (mode === '1d') return dayjs().add(1, 'day').toISOString() + if (mode === '7d') return dayjs().add(7, 'day').toISOString() + if (mode === '30d') return dayjs().add(30, 'day').toISOString() + return '' +} + +export function inferExpiryMode(grant: Pick): JitExpiryMode { + if (!grant.hasExpiry) return 'never' + return 'custom' +} + +export function createEmptyGrant(roleId: string): JitRoleGrantDraft { + return { + roleId, + enabled: false, + expiryMode: '1h', + hasExpiry: true, + expiry: getRelativeDatetimeByMode('1h'), + hasIpRestriction: false, + ipRanges: '', + } +} + +export function createEmptyGrants(roleIds: string[]) { + return roleIds.map((roleId) => createEmptyGrant(roleId)) +} + +export function cloneGrants(grants: JitRoleGrantDraft[]) { + return grants.map((grant) => ({ ...grant })) +} + +export function createDraft(roleIds: string[]): JitUserRuleDraft { + return { memberId: '', grants: createEmptyGrants(roleIds) } +} + +export function mergeRoleIds(baseRoleIds: string[], extraRoleIds: string[]) { + const seen = new Set() + const merged: string[] = [] + + for (const roleId of [...baseRoleIds, ...extraRoleIds]) { + if (seen.has(roleId)) continue + seen.add(roleId) + merged.push(roleId) + } + + return merged +} + +export function draftFromRule(rule: JitUserRule, baseRoleIds: string[]): JitUserRuleDraft { + const byRoleId = new Map(rule.grants.map((grant) => [grant.roleId, grant])) + const mergedRoleIds = mergeRoleIds( + baseRoleIds, + rule.grants.map((grant) => grant.roleId) + ) + + return { + memberId: rule.memberId, + grants: mergedRoleIds.map((roleId) => { + const nextGrant = { + ...createEmptyGrant(roleId), + ...(byRoleId.get(roleId) ?? {}), + } + + return { + ...nextGrant, + expiryMode: inferExpiryMode(nextGrant), + } + }), + } +} + +export function computeStatusFromGrants(grants: JitRoleGrantDraft[]): JitStatus { + const enabledGrants = grants.filter((grant) => grant.enabled) + + let active = 0 + let expired = 0 + let activeIp = 0 + let expiredIp = 0 + + enabledGrants.forEach((grant) => { + const hasIp = grant.hasIpRestriction && grant.ipRanges.trim().length > 0 + + if (!grant.hasExpiry || !grant.expiry) { + active += 1 + if (hasIp) activeIp += 1 + return + } + + const isExpired = dayjs(grant.expiry).isValid() && dayjs(grant.expiry).isBefore(dayjs()) + + if (isExpired) { + expired += 1 + if (hasIp) expiredIp += 1 + return + } + + active += 1 + if (hasIp) activeIp += 1 + }) + + return { active, expired, activeIp, expiredIp } +} + +export function getJitStatusDisplay(status: JitStatus): { badges: JitStatusBadge[] } { + const { active, expired, activeIp } = status + const badges: JitStatusBadge[] = [] + + if (active > 0) { + const label = activeIp > 0 ? `${active} active ยท ${activeIp} IP` : `${active} active` + badges.push({ label, variant: 'success' }) + } + + if (expired > 0) { + badges.push({ label: `${expired} expired`, variant: 'default' }) + } + + return { badges } +} + +export function toUnixSeconds(datetimeIso: string) { + const value = dayjs(datetimeIso) + if (!value.isValid()) return undefined + return value.unix() +}