Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions apps/docs/content/guides/platform/privatelink.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ description: 'Secure private network connectivity to your Supabase database usin
<Admonition type="note">

PrivateLink is available only to Team and Enterprise customers.
Contact support if you would like to create a PrivateLink connection for a read-only replica.

</Admonition>

Expand Down Expand Up @@ -37,19 +36,24 @@ To use PrivateLink with your Supabase project:

## Getting started

### Step 1: Add AWS account
### Step 1: Add connection

Navigate to your project's Integrations section to set up PrivateLink:

1. Go to your Supabase project dashboard
2. Navigate to [**Settings** > **Integrations**](/dashboard/project/_/settings/integrations)
3. Find the **AWS PrivateLink** section
4. Click **Add Account**
5. Enter your AWS Account ID
6. Provide a description for the account (recommended)
7. Click **Add Account** to submit
4. Click **Add connection**
5. Enter the destination AWS account ID
6. Select the database: the primary database or a specific read replica
7. Optionally add a description
8. Click **Add connection** to submit

After submission, Supabase creates a VPC Lattice Resource Configuration for your project and sends an AWS Resource Share to the specified AWS Account ID. This process may take a few moments. Once complete, the account will show a "Ready" status, indicating that the resource share has been sent to your AWS account and is ready to be accepted.
Each database, whether the primary or a read replica, needs its own connection. Create a separate connection for every database you want to reach over PrivateLink.

After submission, Supabase creates a VPC Lattice Resource Configuration for your project and sends an AWS Resource Share to the specified AWS account ID. This process may take a few moments. Once complete, the connection will show a "Ready" status, indicating that the resource share has been sent to your AWS account and is ready to be accepted. You must accept the resource share within 12 hours, or the request expires and can no longer be accepted in AWS. You'll need to create a new connection to try again.

Once ready, select **View connection** to see the VPC Lattice resource configuration ID and ARNs for the connection. This is useful for confirming which resource configuration corresponds to which database when a project has multiple connections, for example one for the primary database and one for each read replica.

### Step 2: Accept resource share

Expand All @@ -61,6 +65,7 @@ Supabase will send you an AWS Resource Share containing the VPC Lattice Resource
3. Go to [Shared with me > Resource shares](https://console.aws.amazon.com/ram/home#SharedResourceShares)
4. Locate the resource share from Supabase.
- The resource share has the format `sspl-[project_ref]-[random alphanumeric string]`
- If your project has multiple connections, for example one for the primary database and one for each read replica, match the share's ARN to the **Resource share ARN** shown for that connection in **View connection** to confirm you're accepting the correct one
5. Click on the resource share name to view details. Review the list of resource shares - it should only include resources of type vpc-lattice:ResourceConfiguration.
6. Click **Accept resource share**
7. Confirm the acceptance in the dialog box
Expand Down Expand Up @@ -95,6 +100,7 @@ In your AWS account, you have two options to establish connectivity:
5. Under Type, select **Resources**
6. In the **Resource configurations** section select the appropriate resource configuration
- The resource configuration name will be in the format `[organisation]-[project-ref]-rc`
- If you have multiple connections, match the **Resource configuration ID** shown for that connection in **View connection** to confirm you select the configuration for the correct database
7. Select your VPC from the dropdown. This should match the VPC you selected for your security group in Step 3
8. Enable the **Enable DNS name** option if you want to use a DNS record instead of the endpoints IP address(es)
9. Choose the appropriate subnets for your network
Expand All @@ -115,6 +121,7 @@ In your AWS account, you have two options to establish connectivity:
4. In the service network details, go to the **Resource configuration associations** tab
5. Click **Create associations**
6. Select the appropriate **Resource configuration** from the dropdown
- If you have multiple connections, match the **Resource configuration ID** shown for that connection in **View connection** to confirm you select the configuration for the correct database
7. Click **Save changes**
8. After creation, you will see the resource configuration in the Resource configurations section of your service network with the status "Active"
9. For connectivity, click on the association details and the domain name will be listed in the **DNS entries** section
Expand Down Expand Up @@ -174,7 +181,7 @@ For maximum security, you can restrict public database access in your project se

## Limitations

- **Read Replicas**: To establish PrivateLink with a Read Replica, reach out to your account rep.
- **Service Scope**: PrivateLink only supports database connections (Postgres and PgBouncer). Other Supabase services (API, Storage, Auth, Realtime) will continue to operate over public internet connections.
- **Feature Evolution**: The setup process and capabilities may evolve as we refine the offering

## Compatibility
Expand Down
123 changes: 83 additions & 40 deletions apps/studio/components/interfaces/Account/TOTPFactors/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import dayjs from 'dayjs'
import { Plus } from 'lucide-react'
import { useState } from 'react'
import { Button } from 'ui'
import { Button, Card, CardContent } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import {
PageSection,
PageSectionAside,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'

import { AddNewFactorModal } from './AddNewFactorModal'
Expand All @@ -14,62 +25,94 @@ export const TOTPFactors = () => {
const [factorToBeDeleted, setFactorToBeDeleted] = useState<string | null>(null)
const { data, isPending: isLoading, isError, isSuccess, error } = useMfaListFactorsQuery()

const totpFactors = data?.totp ?? []
const canAddApp = isSuccess && totpFactors.length < 2
const shouldShowLockoutWarning = isSuccess && totpFactors.length === 1

const handleAddNewApp = () => setIsAddNewFactorOpen(true)

return (
<>
<section className="space-y-3">
<p className="text-sm text-foreground-light">
Use an authenticator app (like Google Authenticator or 1Password) to protect your account.
</p>
<div>
{isLoading && <GenericSkeletonLoader />}
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Multi-factor authentication</PageSectionTitle>
<PageSectionDescription>
Use an authenticator app (like Google Authenticator or 1Password) to protect your
account.
</PageSectionDescription>
</PageSectionSummary>
{canAddApp && (
<PageSectionAside>
<Button variant="primary" icon={<Plus />} onClick={handleAddNewApp}>
Add app
</Button>
</PageSectionAside>
)}
</PageSectionMeta>
<PageSectionContent className="flex flex-col gap-4">
{shouldShowLockoutWarning && (
<Admonition
type="danger"
layout="responsive"
title="Avoid being locked out"
description="Add a backup authenticator app now. Losing access to your only app will permanently lock you out of your account."
actions={
<Button variant="default" icon={<Plus />} onClick={handleAddNewApp}>
Add another app
</Button>
}
/>
)}
{isLoading && (
<Card>
<CardContent>
<GenericSkeletonLoader />
</CardContent>
</Card>
)}
{isError && (
<AlertError error={error} subject="Failed to retrieve account security information" />
)}
{isSuccess && (
<>
<div>
{data.totp.map((factor) => {
return (
<div key={factor.id} className="flex flex-row justify-between py-2">
<p className="text-sm text-foreground flex items-center space-x-2">
<span className="text-foreground-light">Name:</span>{' '}
<span>{factor.friendly_name ?? 'No name provided'}</span>
</p>
<div className="flex items-center gap-4">
<p className="text-sm text-foreground-light">
<Card>
{totpFactors.length === 0 ? (
<CardContent>
<p className="text-sm text-foreground-lighter">No authenticator apps yet.</p>
</CardContent>
) : (
<div className="divide-y">
{totpFactors.map((factor) => (
<CardContent key={factor.id} className="flex justify-between items-center py-4">
<div>
<p className="text-sm">{factor.friendly_name ?? 'No name provided'}</p>
<p className="text-sm text-foreground-lighter">
Added on {dayjs(factor.created_at).format(DATETIME_FORMAT)}
</p>
<Button
size="tiny"
variant="default"
onClick={() => setFactorToBeDeleted(factor.id)}
>
Remove
</Button>
</div>
</div>
)
})}
</div>
{data.totp.length < 2 ? (
<>
<div className="pt-2">
<Button onClick={() => setIsAddNewFactorOpen(true)}>Add new app</Button>
</div>
</>
) : null}
</>
<Button
size="tiny"
variant="default"
onClick={() => setFactorToBeDeleted(factor.id)}
>
Delete{' '}
</Button>
</CardContent>
))}
</div>
)}
</Card>
)}
</div>
</section>
</PageSectionContent>
</PageSection>
<AddNewFactorModal
visible={isAddNewFactorOpen}
onClose={() => setIsAddNewFactorOpen(false)}
/>
<DeleteFactorModal
visible={factorToBeDeleted !== null}
factorId={factorToBeDeleted}
lastFactorToBeDeleted={data?.totp.length === 1}
lastFactorToBeDeleted={totpFactors.length === 1}
onClose={() => setFactorToBeDeleted(null)}
/>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -676,8 +676,8 @@ export const ProjectCreationForm = ({
label="GitHub (optional)"
description={
<>
Ideal for agent-first workflows: update your schema in code, push it
to GitHub, and Supabase deploys the changes automatically.{' '}
Ideal for agent-first workflows. Update your schema in code and push
it to GitHub. Supabase deploys the changes.{' '}
<a
href="https://supabase.com/docs/guides/deployment/branching/github-integration"
target="_blank"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const DropReplicaConfirmationModal = ({
<span>
Remove the replica{' '}
<InlineLink href={`/project/${projectRef}/settings/integrations`}>
PrivateLink association
PrivateLink connection
</InlineLink>{' '}
before dropping this read replica
</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'

import { getConnectionStatusUi, type PrivateLinkConnectionStatus } from './AWSPrivateLink.utils'

describe('getConnectionStatusUi', () => {
it.each([
[
'ASSOCIATION_ACCEPTED',
{
badge: 'Connected',
badgeVariant: 'success',
title: 'This connection is active',
},
],
[
'READY',
{
badge: 'Ready',
badgeVariant: 'success',
title: 'Waiting for the AWS account owner to accept',
description: 'This request expires after 12 hours.',
},
],
[
'CREATING',
{
badge: 'Creating',
badgeVariant: 'warning',
title: 'This connection is being created',
},
],
[
'DELETING',
{
badge: 'Deleting',
badgeVariant: 'destructive',
title: 'This connection is being deleted',
},
],
[
'ASSOCIATION_REQUEST_EXPIRED',
{
badge: 'Expired',
badgeVariant: 'destructive',
title: 'This request has expired',
},
],
[
'CREATION_FAILED',
{
badge: 'Failed',
badgeVariant: 'destructive',
title: "Couldn't create this connection",
},
],
] as const satisfies ReadonlyArray<
[PrivateLinkConnectionStatus, Partial<ReturnType<typeof getConnectionStatusUi>>]
>)('maps %s', (status, expected) => {
expect(getConnectionStatusUi(status)).toMatchObject(expected)
})

it('returns unknown copy when status is missing', () => {
const ui = getConnectionStatusUi()

expect(ui.badge).toBe('Unknown')
expect(ui.badgeVariant).toBe('default')
expect(ui.title).toBe("Couldn't determine this connection's status")
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { AWSAccount } from '@/data/aws-accounts/aws-accounts-query'

export type PrivateLinkConnectionStatus = AWSAccount['status']

type BadgeVariant = 'success' | 'warning' | 'destructive' | 'default'

export type ConnectionStatusUi = {
title: string
description: string
badge: string
badgeVariant: BadgeVariant
}

const CONNECTION_STATUS_UI: Record<PrivateLinkConnectionStatus, ConnectionStatusUi> = {
ASSOCIATION_ACCEPTED: {
title: 'This connection is active',
description: 'The AWS account owner has accepted the resource share.',
badge: 'Connected',
badgeVariant: 'success',
},
READY: {
title: 'Waiting for the AWS account owner to accept',
description: 'This request expires after 12 hours.',
badge: 'Ready',
badgeVariant: 'success',
},
CREATING: {
title: 'This connection is being created',
description: '',
badge: 'Creating',
badgeVariant: 'warning',
},
DELETING: {
title: 'This connection is being deleted',
description: '',
badge: 'Deleting',
badgeVariant: 'destructive',
},
ASSOCIATION_REQUEST_EXPIRED: {
title: 'This request has expired',
description: 'Add a new connection to try again.',
badge: 'Expired',
badgeVariant: 'destructive',
},
CREATION_FAILED: {
title: "Couldn't create this connection",
description: 'Add a new connection to try again.',
badge: 'Failed',
badgeVariant: 'destructive',
},
}

const UNKNOWN_STATUS_UI: ConnectionStatusUi = {
title: "Couldn't determine this connection's status",
description: '',
badge: 'Unknown',
badgeVariant: 'default',
}

export function getConnectionStatusUi(status?: PrivateLinkConnectionStatus): ConnectionStatusUi {
if (!status) return UNKNOWN_STATUS_UI
return CONNECTION_STATUS_UI[status] ?? UNKNOWN_STATUS_UI
}
Loading
Loading