From 06c1237c52083fbbc31f6a0d7d5dc60de05b767e Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:34:11 +1100 Subject: [PATCH] JIT sheet --- .../Settings/Database/JITAccess.tsx | 715 +++++++++++++++--- 1 file changed, 602 insertions(+), 113 deletions(-) diff --git a/apps/studio/components/interfaces/Settings/Database/JITAccess.tsx b/apps/studio/components/interfaces/Settings/Database/JITAccess.tsx index a72c82bbe3a..9baa97ebf63 100644 --- a/apps/studio/components/interfaces/Settings/Database/JITAccess.tsx +++ b/apps/studio/components/interfaces/Settings/Database/JITAccess.tsx @@ -1,24 +1,33 @@ -import { EllipsisVertical, Eye, Trash2, UserPlus } from 'lucide-react' -import Link from 'next/link' -import { useState } from 'react' - import { useParams } from 'common' +import { DatePicker } from 'components/ui/DatePicker' import { DocsButton } from 'components/ui/DocsButton' +import { InlineLink } from 'components/ui/InlineLink' import { DOCS_URL } from 'lib/constants' +import { Calendar, EllipsisVertical, Pencil, Plus, Trash2, UserPlus } from 'lucide-react' +import Link from 'next/link' +import { useMemo, useState } from 'react' import { Badge, Button, Card, CardContent, + Checkbox_Shadcn_, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + Input_Shadcn_, + ScrollArea, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, Sheet, SheetContent, SheetDescription, + SheetFooter, SheetHeader, - SheetSection, SheetTitle, Switch, Table, @@ -38,74 +47,538 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns' -import { InlineLink } from 'components/ui/InlineLink' import { Admonition } from 'ui-patterns/admonition' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' -// Prototype: JIT state is an array of rules; display is derived from it -type JitRole = { expired: boolean; ipRestricted: boolean; role: string } +type JitStatus = { active: number; expired: number; ipRestricted?: boolean } -function getJitStatusDisplay(roles: JitRole[]): { +type JITMember = { id: string; email: string; name?: string } + +type JITRoleOption = { + id: string label: string - variant: 'default' | 'success' | 'warning' -} { - const activeCount = roles.filter((r) => !r.expired).length - const expiredCount = roles.filter((r) => r.expired).length - const activeIp = roles.some((r) => !r.expired && r.ipRestricted) - const expiredIp = roles.some((r) => r.expired && r.ipRestricted) - - const activePart = activeCount ? `${activeCount} active${activeIp ? ' · IP' : ''}` : null - const expiredPart = expiredCount ? `${expiredCount} expired${expiredIp ? ' · IP' : ''}` : null - const label = [activePart, expiredPart].filter(Boolean).join(', ') || 'No sessions' - - if (activeCount > 0 && expiredCount === 0) return { label, variant: 'success' } - if (activeCount === 0 && expiredCount > 0) return { label, variant: 'warning' } - return { label, variant: 'default' } + description?: string } -type JITUser = { id: string; email: string; name?: string; roles: JitRole[] } -const MOCK_USERS: JITUser[] = [ +type JITRoleGrantDraft = { + roleId: string + enabled: boolean + expiryMode: '1h' | '1d' | '7d' | '30d' | 'custom' | 'never' + hasExpiry: boolean + expiry: string + hasIpRestriction: boolean + ipRanges: string +} + +type JITUserRuleDraft = { + memberId: string + grants: JITRoleGrantDraft[] +} + +type JITUserRule = { + id: string + memberId: string + email: string + name?: string + grants: JITRoleGrantDraft[] + status: JitStatus +} + +type SheetMode = 'add' | 'edit' + +const AVAILABLE_ROLES: JITRoleOption[] = [ { - id: '1', - email: 'alice@example.com', - name: 'Alice', - roles: [ - { expired: false, ipRestricted: false, role: 'fake_db_role' }, - { expired: true, ipRestricted: false, role: 'other_role' }, - ], + id: 'supabase_read_only_user', + label: 'supabase_read_only_user', + description: 'Read-only access to all schemas', }, { - id: '2', - email: 'bob@example.com', - roles: [{ expired: true, ipRestricted: false, role: 'fake_db_role' }], + id: 'postgres', + label: 'postgres', + description: 'Full database superuser access', }, { - id: '3', - email: 'carol@example.com', - name: 'Carol', - roles: [{ expired: false, ipRestricted: true, role: 'fake_db_role' }], + id: 'custom_role_a', + label: 'custom_role_a', }, { - id: '4', - email: 'dave@example.com', - name: 'Dave', - roles: [ - { expired: false, ipRestricted: true, role: 'fake_db_role' }, - { expired: true, ipRestricted: false, role: 'other_role' }, - ], + id: 'custom_role_b', + label: 'custom_role_b', + }, + { + id: 'custom_role_c', + label: 'custom_role_c', }, ] + +const MOCK_MEMBERS: JITMember[] = [ + { id: 'member-1', name: 'Alice', email: 'alice@example.com' }, + { id: 'member-2', name: 'Bob', email: 'bob@example.com' }, + { id: 'member-3', name: 'Carol', email: 'carol@example.com' }, + { id: 'member-4', name: 'Dave', email: 'dave@example.com' }, + { id: 'member-5', name: 'Eve', email: 'eve@example.com' }, + { id: 'member-6', name: 'Frank', email: 'frank@example.com' }, +] + // Prototype: mock Postgres version const isPostgresVersionOutdated = false +function pad(value: number) { + return String(value).padStart(2, '0') +} + +function toDatetimeLocalValue(date: Date) { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad( + date.getHours() + )}:${pad(date.getMinutes())}` +} + +function relativeDatetimeLocal(hoursDelta: number) { + const date = new Date() + date.setHours(date.getHours() + hoursDelta) + return toDatetimeLocalValue(date) +} + +function getRelativeDatetimeByMode(mode: JITRoleGrantDraft['expiryMode']) { + if (mode === '1h') return relativeDatetimeLocal(1) + if (mode === '1d') return relativeDatetimeLocal(24) + if (mode === '7d') return relativeDatetimeLocal(24 * 7) + if (mode === '30d') return relativeDatetimeLocal(24 * 30) + return '' +} + +function inferExpiryMode(grant: JITRoleGrantDraft): JITRoleGrantDraft['expiryMode'] { + if (!grant.hasExpiry) return 'never' + return 'custom' +} + +function createEmptyGrant(roleId: string): JITRoleGrantDraft { + return { + roleId, + enabled: false, + expiryMode: '30d', + hasExpiry: false, + expiry: '', + hasIpRestriction: false, + ipRanges: '', + } +} + +function createEmptyGrants(): JITRoleGrantDraft[] { + return AVAILABLE_ROLES.map((role) => createEmptyGrant(role.id)) +} + +function cloneGrants(grants: JITRoleGrantDraft[]) { + return grants.map((grant) => ({ ...grant })) +} + +function createDraft(): JITUserRuleDraft { + return { memberId: '', grants: createEmptyGrants() } +} + +function withGrantOverrides( + overrides: Array & { roleId: string }> +): JITRoleGrantDraft[] { + const byRoleId = new Map(overrides.map((override) => [override.roleId, override])) + + return AVAILABLE_ROLES.map((role) => ({ + ...createEmptyGrant(role.id), + ...(byRoleId.get(role.id) ?? {}), + roleId: role.id, + })) +} + +function computeStatusFromGrants(grants: JITRoleGrantDraft[]): JitStatus { + const enabled = grants.filter((grant) => grant.enabled) + + let active = 0 + let expired = 0 + + enabled.forEach((grant) => { + if (!grant.hasExpiry || !grant.expiry) { + active += 1 + return + } + + const expiryMs = new Date(grant.expiry).getTime() + if (Number.isNaN(expiryMs) || expiryMs >= Date.now()) active += 1 + else expired += 1 + }) + + const ipRestricted = enabled.some( + (grant) => grant.hasIpRestriction && grant.ipRanges.trim().length > 0 + ) + + return { active, expired, ipRestricted } +} + +function getJitStatusDisplay(status: JitStatus): { + label: string + variant: 'default' | 'success' | 'warning' +} { + const { active, expired, ipRestricted } = status + const parts: string[] = [] + + if (active > 0) parts.push(expired > 0 ? `${active} active, ${expired} expired` : 'Active') + else if (expired > 0) parts.push('Expired') + + if (ipRestricted) parts.push('IP restricted') + + const label = parts.length ? parts.join(' · ') : 'No sessions' + + if (active > 0 && expired === 0 && !ipRestricted) return { label, variant: 'success' } + if (active === 0 && expired > 0) return { label, variant: 'warning' } + return { label, variant: 'default' } +} + +function createRuleFromMember( + id: string, + memberId: string, + grants: JITRoleGrantDraft[], + members: JITMember[] +): JITUserRule { + const member = members.find((m) => m.id === memberId) + const nextGrants = cloneGrants(grants) + + return { + id, + memberId, + email: member?.email ?? 'unknown@example.com', + name: member?.name, + grants: nextGrants, + status: computeStatusFromGrants(nextGrants), + } +} + +function draftFromRule(rule: JITUserRule): JITUserRuleDraft { + const byRoleId = new Map(rule.grants.map((grant) => [grant.roleId, grant])) + return { + memberId: rule.memberId, + grants: AVAILABLE_ROLES.map((role) => { + const nextGrant = { + ...createEmptyGrant(role.id), + ...(byRoleId.get(role.id) ?? {}), + } + return { + ...nextGrant, + expiryMode: inferExpiryMode(nextGrant), + } + }), + } +} + +const MOCK_USERS: JITUserRule[] = [ + createRuleFromMember( + 'rule-1', + 'member-1', + withGrantOverrides([ + { roleId: 'supabase_read_only_user', enabled: true, expiryMode: '30d' }, + { + roleId: 'postgres', + enabled: true, + hasExpiry: true, + expiryMode: 'custom', + expiry: relativeDatetimeLocal(-12), + }, + ]), + MOCK_MEMBERS + ), + createRuleFromMember( + 'rule-2', + 'member-2', + withGrantOverrides([ + { + roleId: 'supabase_read_only_user', + enabled: true, + hasExpiry: true, + expiryMode: 'custom', + expiry: relativeDatetimeLocal(-4), + }, + ]), + MOCK_MEMBERS + ), + createRuleFromMember( + 'rule-3', + 'member-3', + withGrantOverrides([ + { + roleId: 'postgres', + enabled: true, + expiryMode: '30d', + hasIpRestriction: true, + ipRanges: '203.0.113.0/24', + }, + ]), + MOCK_MEMBERS + ), + createRuleFromMember( + 'rule-4', + 'member-4', + withGrantOverrides([ + { + roleId: 'custom_role_b', + enabled: true, + expiryMode: '30d', + hasIpRestriction: true, + ipRanges: '198.51.100.0/24', + }, + { + roleId: 'postgres', + enabled: true, + hasExpiry: true, + expiryMode: 'custom', + expiry: relativeDatetimeLocal(6), + }, + ]), + MOCK_MEMBERS + ), +] + +function RoleRuleEditor({ + role, + grant, + onChange, +}: { + role: JITRoleOption + grant: JITRoleGrantDraft + onChange: (next: JITRoleGrantDraft) => void +}) { + const isSuperuserRole = role.id === 'postgres' + const showIpEditor = grant.hasIpRestriction || grant.ipRanges.trim().length > 0 + + return ( +
+
+ onChange({ ...grant, enabled: value === true })} + aria-label={`Enable ${role.label}`} + className="mt-0.5" + /> +
+
+ {role.label} +
+ {role.description && ( +

+ {role.description} +

+ )} +
+
+ + {grant.enabled && ( +
+ {isSuperuserRole && ( +
+

+ Superuser access grants full database control +

+

+ postgres has full administrative access + and bypasses row-level security. Even temporary access can modify or delete any + data. Consider using a custom Postgres role with only the permissions required. +

+
+ +
+
+ )} + +
+

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 || relativeDatetimeLocal(24), + }) + return + } + + onChange({ + ...grant, + expiryMode: nextMode, + hasExpiry: true, + expiry: getRelativeDatetimeByMode(nextMode), + }) + }} + > + + + + + 1 hour + 1 day + 7 days + 30 days + Custom + No expiry + + +
+ + {grant.expiryMode === 'custom' && ( + { + const selectedDate = date.to || date.from + onChange({ + ...grant, + hasExpiry: true, + expiry: selectedDate ? toDatetimeLocalValue(new Date(selectedDate)) : '', + }) + }} + > + + + Select date + + + )} +
+
+ +
+

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

+ + {showIpEditor ? ( +
+ + onChange({ + ...grant, + hasIpRestriction: event.target.value.trim().length > 0, + ipRanges: event.target.value, + }) + } + placeholder="e.g 192.168.0.0/24" + /> + +
+ ) : ( +
+ +
+ )} +
+
+ )} +
+ ) +} export const JITAccess = () => { const { ref: projectRef } = useParams() + const [enabled, setEnabled] = useState(false) - const [selectedUser, setSelectedUser] = useState(null) - // const [users, setUsers] = useState([]) - const [users, setUsers] = useState(MOCK_USERS) - const selectedStatusDisplay = selectedUser ? getJitStatusDisplay(selectedUser.roles) : null + const [users, setUsers] = useState(MOCK_USERS) + const [sheetOpen, setSheetOpen] = useState(false) + const [sheetMode, setSheetMode] = useState('add') + const [editingUserId, setEditingUserId] = useState(null) + const [draft, setDraft] = useState(() => createDraft()) + + const enabledRoleCount = useMemo( + () => draft.grants.filter((grant) => grant.enabled).length, + [draft.grants] + ) + const canSave = Boolean(draft.memberId) && enabledRoleCount > 0 + + const closeSheet = () => { + setSheetOpen(false) + } + + const openAddUserSheet = () => { + setSheetMode('add') + setEditingUserId(null) + setDraft(createDraft()) + setSheetOpen(true) + } + + const openEditUserSheet = (user: JITUserRule) => { + setSheetMode('edit') + setEditingUserId(user.id) + setDraft(draftFromRule(user)) + setSheetOpen(true) + } + + const updateGrant = ( + roleId: string, + updater: (grant: JITRoleGrantDraft) => JITRoleGrantDraft + ) => { + setDraft((prev) => ({ + ...prev, + grants: prev.grants.map((grant) => (grant.roleId === roleId ? updater(grant) : grant)), + })) + } + + const handleSave = () => { + if (!canSave) return + + const ruleId = sheetMode === 'edit' && editingUserId ? editingUserId : `rule-${Date.now()}` + const nextRule = createRuleFromMember(ruleId, draft.memberId, draft.grants, MOCK_MEMBERS) + + setUsers((prev) => { + if (sheetMode === 'edit' && editingUserId) { + return prev.map((user) => (user.id === editingUserId ? nextRule : user)) + } + return [...prev, nextRule] + }) + + closeSheet() + } return ( @@ -123,7 +596,6 @@ export const JITAccess = () => { label="JIT access" description="Allow time-limited database access to specific project members." > - {/* Swich and tooltip */}
@@ -137,9 +609,7 @@ export const JITAccess = () => {
{isPostgresVersionOutdated && ( - - Upgrade Postgres to use JIT - + Upgrade Postgres to use JIT )} @@ -174,7 +644,7 @@ export const JITAccess = () => { Define which members can receive temporary database access.

- @@ -190,11 +660,9 @@ export const JITAccess = () => { - - {/* Empty state */} {users.length === 0 ? ( - +

No users yet

Add a user above to allow JIT access. @@ -203,32 +671,32 @@ export const JITAccess = () => { ) : ( users.map((user) => { - const statusDisplay = getJitStatusDisplay(user.roles) + const statusDisplay = getJitStatusDisplay(user.status) + const enabledGrants = user.grants.filter((grant) => grant.enabled) + return ( { if ((event.target as HTMLElement).closest('button')) return - setSelectedUser(user) + openEditUserSheet(user) }} onKeyDown={(event) => { if ((event.target as HTMLElement).closest('button')) return if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - setSelectedUser(user) + openEditUserSheet(user) } }} tabIndex={0} > - {user.name &&

{user.name}

} -

- {user.email} -

+ {user.name &&

{user.name}

} +

{user.email}

- {user.roles.length} role{user.roles.length > 1 ? 's' : ''} + {enabledGrants.length} role{enabledGrants.length === 1 ? '' : 's'} {statusDisplay.label} @@ -245,15 +713,14 @@ export const JITAccess = () => { /> - setSelectedUser(user)} + onClick={() => openEditUserSheet(user)} > - - Inspect + + Edit - { }}> + {}}> Delete @@ -266,60 +733,82 @@ export const JITAccess = () => { )}
- )} - !open && setSelectedUser(null)}> - + !open && closeSheet()}> + - {selectedUser?.name ?? selectedUser?.email ?? 'User details'} + {sheetMode === 'edit' ? 'Edit JIT access' : 'Grant JIT access'} - - View JIT access details for this user. + + Configure user role grants and restrictions for JIT database access. -
- -
- {selectedUser && ( - <> - {selectedUser.name && ( -
- Name - {selectedUser.name} + + +
+ + setDraft((prev) => ({ ...prev, memberId: value }))} + > + + + + + {MOCK_MEMBERS.map((member) => ( + + {member.name ? `${member.name} (${member.email})` : member.email} + + ))} + + + + + +
+ {AVAILABLE_ROLES.map((role, index) => { + const grant = + draft.grants.find((draftGrant) => draftGrant.roleId === role.id) ?? + createEmptyGrant(role.id) + + return ( +
0 ? 'border-t' : ''}> + updateGrant(role.id, () => next)} + />
- )} -
- Email - {selectedUser.email} -
-
- Roles - - {selectedUser.roles.length} role{selectedUser.roles.length > 1 ? 's' : ''} - -
-
- Status - - {selectedStatusDisplay && ( - - {selectedStatusDisplay.label} - - )} - -
- - )} -
- -
+ ) + })} +
+ +

+ Use custom{' '} + + Postgres roles + {' '} + to enforce least-privilege access when enabling JIT. +

+ +
+ + + + + + - + ) }