diff --git a/apps/studio/data/jit-db-access/jit-db-access-roles.utils.test.ts b/apps/studio/data/jit-db-access/jit-db-access-roles.utils.test.ts index 5ec10ff265c..13a894ff482 100644 --- a/apps/studio/data/jit-db-access/jit-db-access-roles.utils.test.ts +++ b/apps/studio/data/jit-db-access/jit-db-access-roles.utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest' import { getActiveTemporaryAccessRoles, + getNextTemporaryAccessExpirySeconds, isTemporaryAccessRoleExpired, } from './jit-db-access-roles.utils' @@ -33,3 +34,28 @@ describe('getActiveTemporaryAccessRoles', () => { ).toEqual(['postgres', 'analytics']) }) }) + +describe('getNextTemporaryAccessExpirySeconds', () => { + test('returns the soonest future expiry and ignores missing or past ones', () => { + expect(getNextTemporaryAccessExpirySeconds(undefined, 1_000)).toBeUndefined() + expect( + getNextTemporaryAccessExpirySeconds( + { + user_roles: [ + { role: 'postgres', expires_at: 500 }, + { role: 'analytics' }, + { role: 'postgres', expires_at: 2_000 }, + { role: 'supabase_read_only_user', expires_at: 1_500 }, + ], + }, + 1_000 + ) + ).toBe(1_500) + expect( + getNextTemporaryAccessExpirySeconds( + { user_roles: [{ role: 'postgres', expires_at: 1_701_000_000_000 }] }, + 1_700_000_000 + ) + ).toBe(1_701_000_000) + }) +}) diff --git a/apps/studio/data/jit-db-access/jit-db-access-roles.utils.ts b/apps/studio/data/jit-db-access/jit-db-access-roles.utils.ts index ae5369aee55..e2f66324e97 100644 --- a/apps/studio/data/jit-db-access/jit-db-access-roles.utils.ts +++ b/apps/studio/data/jit-db-access/jit-db-access-roles.utils.ts @@ -5,13 +5,32 @@ export type TemporaryAccessRoleGrant = { const MILLISECONDS_THRESHOLD = 1e12 +function toExpirySeconds(expiresAt: number) { + return expiresAt > MILLISECONDS_THRESHOLD ? expiresAt / 1000 : expiresAt +} + export function isTemporaryAccessRoleExpired( expiresAt: number | undefined, nowSeconds: number ): boolean { if (!expiresAt) return false - const expiresSeconds = expiresAt > MILLISECONDS_THRESHOLD ? expiresAt / 1000 : expiresAt - return expiresSeconds <= nowSeconds + return toExpirySeconds(expiresAt) <= nowSeconds +} + +export function getNextTemporaryAccessExpirySeconds( + grants: { user_roles?: TemporaryAccessRoleGrant[] } | undefined, + nowSeconds: number +): number | undefined { + if (!grants?.user_roles?.length) return + + let next: number | undefined + for (const grant of grants.user_roles) { + if (!grant.role || grant.expires_at == null) continue + const expiresSeconds = toExpirySeconds(grant.expires_at) + if (expiresSeconds <= nowSeconds) continue + if (next == null || expiresSeconds < next) next = expiresSeconds + } + return next } export function getActiveTemporaryAccessRoles( diff --git a/apps/studio/data/jit-db-access/use-active-temporary-access-roles.ts b/apps/studio/data/jit-db-access/use-active-temporary-access-roles.ts index 1c372166504..2828ba01e52 100644 --- a/apps/studio/data/jit-db-access/use-active-temporary-access-roles.ts +++ b/apps/studio/data/jit-db-access/use-active-temporary-access-roles.ts @@ -1,9 +1,16 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useIsJitDbAccessEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import { getActiveTemporaryAccessRoles } from '@/data/jit-db-access/jit-db-access-roles.utils' +import { + getActiveTemporaryAccessRoles, + getNextTemporaryAccessExpirySeconds, +} from '@/data/jit-db-access/jit-db-access-roles.utils' import { useJitDbAccessSelfQuery } from '@/data/jit-db-access/jit-db-access-self-query' +function readNowSeconds() { + return Math.floor(Date.now() / 1000) +} + export function useActiveTemporaryAccessRoles( projectRef: string | undefined, { enabled = true }: { enabled?: boolean } = {} @@ -13,11 +20,31 @@ export function useActiveTemporaryAccessRoles( { projectRef }, { enabled: enabled && isJitEnabled } ) + const [nowSeconds, setNowSeconds] = useState(readNowSeconds) + + useEffect(() => { + if (!enabled || !isJitEnabled || isError) return + + const now = readNowSeconds() + if (now !== nowSeconds) { + setNowSeconds(now) + return + } + + const nextExpiry = getNextTemporaryAccessExpirySeconds(data, nowSeconds) + if (nextExpiry == null) return + + const timeout = window.setTimeout( + () => setNowSeconds(readNowSeconds()), + Math.max(0, nextExpiry * 1000 - Date.now()) + ) + return () => window.clearTimeout(timeout) + }, [data, enabled, isError, isJitEnabled, nowSeconds]) const activeRoles = useMemo(() => { if (!isJitEnabled || isError) return [] - return getActiveTemporaryAccessRoles(data, Math.floor(Date.now() / 1000)) - }, [data, isError, isJitEnabled]) + return getActiveTemporaryAccessRoles(data, nowSeconds) + }, [data, isError, isJitEnabled, nowSeconds]) return { activeRoles,