feat(studio): let High Compliance projects opt-in to Assistant data access (#50548)

Orgs with the HIPAA add-on had the Assistant's opt-in level forced to
`disabled` on any project marked High Compliance, regardless of what the
org picked in its AI settings. The restriction predated our AI provider
BAAs. The consequence is those users see the Assistant failing to answer
questions about their data w/ no clear path how to fix it, even though
the LLM provider supports this use case.

This PR removes these Assistant restrictions on the server and client so
those projects honor the org's chosen level. Braintrust conversation
tracing is unchanged and still blocked for these projects, see [this
test
case](https://github.com/supabase/supabase/blob/b9800ccf16/apps/studio/lib/ai/braintrust-logger.test.ts#L16-L20).
See
[comments](https://linear.app/supabase/issue/AI-1153/allow-hipaa-orgs-to-opt-in-to-assistant-data-access-for-high#comment-485a0d46)
for legal approval and conditions.

The client-side changes enable features like "Debug with AI" on SQL
query failures, “Generate/Rename with AI” for snippet titles, and
generated Assistant chat titles for these customers.

The AI opt-in copy now adds a reminder to obtain consent from data
subjects, linking the [shared responsibility
model](https://supabase.com/docs/guides/deployment/shared-responsibility-model)
based also on [this
comment](https://linear.app/supabase/issue/AI-1153/allow-hipaa-orgs-to-opt-in-to-assistant-data-access-for-high#comment-f81ee610).

<img width="400" alt="CleanShot 2026-09-17 at 5 01 02 PM@2x"
src="https://github.com/user-attachments/assets/d02123f2-3e32-4d83-9f98-7d15e59222ef"
/>

To test with a HIPAA-enabled project in staging, you can use this [Plan
Change
[Staging]](https://app.hex.tech/supabase/app/Plan-Change-Staging-032BD32jo1EaisCS85qunf/latest)
Hex to add the HIPAA add-on. Once the add-on is present, you can turn on
High Compliance from a project's settings. Also in org settings, crank
up the Assistant data opt-in level and verify the Assistant is able to
answer questions about the project's data.

My results testing with opt-in level "Schema, Logs & Database Data":

| High compliance setting | Data opt-in working |
|--------|--------|
| <img width="1302" height="422" alt="CleanShot 2026-09-17 at 5 03 36
PM@2x"
src="https://github.com/user-attachments/assets/c416371b-2eb8-49df-9c07-6d8eababb443"
/> | <img width="1566" height="1516" alt="CleanShot 2026-09-17 at 5 05
14 PM@2x"
src="https://github.com/user-attachments/assets/39624355-7f8f-46ce-9f08-a8acfb9da830"
/> |

Closes AI-1153


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## New Features

- AI-assisted query renaming, snippet title generation, debugging, and
tools now follow organization AI opt-in settings rather than project
HIPAA status.
- Debugging assistance and AI actions remain available for eligible
users without additional HIPAA-based blocking.
- AI metadata warnings consistently show standard opt-in messaging and
permission settings.
- AI settings remind users to obtain consent before entering personal
data and link to shared responsibility guidance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Matt RossmanandJoshen Lim authored and GitHub committed 2026-09-18 08:21:50 -04:00
1 parent ef527f447a
commit 4bb36b944f
26 files changed
+86 -374

No files matched your search

@@ -36,7 +36,6 @@ export const ExplorerChatToolbar = ({
isChatLoading,
showMetadataWarning,
updatedOptInSinceMCP,
isHipaaProjectDisallowed,
aiOptInLevel,
}: ExplorerChatToolbarProps) => {
const snap = useAiAssistantStateSnapshot()
@@ -120,7 +119,6 @@ export const ExplorerChatToolbar = ({
onVisibleChange={setIsOptInModalOpen}
showMetadataWarning={showMetadataWarning}
updatedOptInSinceMCP={updatedOptInSinceMCP}
isHipaaProjectDisallowed={isHipaaProjectDisallowed}
aiOptInLevel={aiOptInLevel}
/>
@@ -1,81 +0,0 @@
import { screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { QueryResultError } from './QueryResultError'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
const mocks = vi.hoisted(() => ({
createChat: vi.fn(),
useParams: vi.fn(),
}))
vi.mock('@/lib/constants', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@/lib/constants')
return { ...actual, IS_PLATFORM: false }
})
vi.mock('common', async (importOriginal) => {
const actual = await importOriginal<typeof import('common')>()
return { ...actual, useParams: () => mocks.useParams() }
})
vi.mock('../hooks', () => ({
useCreateChat: () => ({ createChat: mocks.createChat, isCreating: false }),
}))
vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
useSelectedOrganizationQuery: () => ({ data: undefined }),
}))
// Self-hosted has no orgs/billing, so these eligibility queries are expected to never
// resolve (disabled or failing) - the dropdown must not stay hidden waiting on them.
vi.mock('@/data/subscriptions/org-subscription-query', () => ({
useOrgSubscriptionQuery: () => ({ data: undefined, isSuccess: false }),
}))
vi.mock('@/data/config/project-settings-v2-query', () => ({
useProjectSettingsV2Query: () => ({ data: undefined, isSuccess: false }),
}))
describe('QueryResultError (self-hosted)', () => {
beforeEach(() => {
mocks.useParams.mockReturnValue({ ref: 'default' })
addAPIMock({
method: 'get',
path: '/platform/projects/:ref',
response: {
id: 1,
ref: 'default',
organization_id: 1,
name: 'Test Project',
status: 'ACTIVE_HEALTHY',
cloud_provider: 'AWS',
region: 'us-east-1',
db_host: 'db.default.supabase.co',
restUrl: 'https://default.supabase.co/rest/v1/',
inserted_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
subscription_id: 'sub_123',
is_branch_enabled: false,
is_physical_backups_enabled: false,
high_availability: false,
integration_source: null,
connectionString: 'postgresql://postgres@localhost:5432/postgres',
is_hibernating: false,
},
})
})
it('renders the assistant dropdown without waiting on HIPAA eligibility queries', () => {
customRender(
<QueryResultError
error={{ message: 'relation "foo" does not exist' }}
sql="select * from foo;"
source="database"
/>
)
expect(screen.getByRole('button', { name: 'Debug with Assistant' })).toBeInTheDocument()
})
})
@@ -10,8 +10,6 @@ const mocks = vi.hoisted(() => ({
createChat: vi.fn(),
useParams: vi.fn(),
mockCopyToClipboard: vi.fn(),
useOrgSubscriptionQuery: vi.fn(),
useProjectSettingsV2Query: vi.fn(),
}))
vi.mock('common', async (importOriginal) => {
@@ -19,13 +17,6 @@ vi.mock('common', async (importOriginal) => {
return { ...actual, useParams: () => mocks.useParams() }
})
// This file covers the platform-mode HIPAA eligibility gate; the self-hosted bypass is
// covered separately in QueryResultError.selfhosted.test.tsx.
vi.mock('@/lib/constants', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@/lib/constants')
return { ...actual, IS_PLATFORM: true }
})
// CopyButton and AiAssistantDropdown write via copyToClipboard from 'ui'. Stub just that
// export so we can assert the value handed to the clipboard without depending on jsdom's
// document.hasFocus() / navigator.clipboard. Everything else in 'ui' stays real.
@@ -42,19 +33,9 @@ vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
useSelectedOrganizationQuery: () => ({ data: { slug: 'default-org' } }),
}))
vi.mock('@/data/subscriptions/org-subscription-query', () => ({
useOrgSubscriptionQuery: () => mocks.useOrgSubscriptionQuery(),
}))
vi.mock('@/data/config/project-settings-v2-query', () => ({
useProjectSettingsV2Query: () => mocks.useProjectSettingsV2Query(),
}))
describe('QueryResultError', () => {
beforeEach(() => {
mocks.useParams.mockReturnValue({ ref: 'default' })
mocks.useOrgSubscriptionQuery.mockReturnValue({ data: undefined, isSuccess: true })
mocks.useProjectSettingsV2Query.mockReturnValue({ data: undefined, isSuccess: true })
// useTrack() (invoked by AiAssistantDropdown) reads the selected project to attach
// telemetry context, so the platform project fetch needs a handler even though this
// component doesn't read project data itself.
@@ -145,34 +126,4 @@ describe('QueryResultError', () => {
expect(screen.queryByRole('button', { name: 'Debug with Assistant' })).not.toBeInTheDocument()
})
it('does not render the assistant dropdown while HIPAA eligibility is still resolving', () => {
mocks.useOrgSubscriptionQuery.mockReturnValue({ data: undefined, isSuccess: false })
customRender(
<QueryResultError
error={{ message: 'relation "foo" does not exist' }}
sql="select * from foo;"
source="database"
/>
)
expect(screen.queryByRole('button', { name: 'Debug with Assistant' })).not.toBeInTheDocument()
})
it('does not render the assistant dropdown when an eligibility query is disabled or failed', () => {
// A disabled or failed query also settles with isSuccess: false forever - same as
// still-loading from this component's point of view, so it stays denied.
mocks.useProjectSettingsV2Query.mockReturnValue({ data: undefined, isSuccess: false })
customRender(
<QueryResultError
error={{ message: 'relation "foo" does not exist' }}
sql="select * from foo;"
source="database"
/>
)
expect(screen.queryByRole('button', { name: 'Debug with Assistant' })).not.toBeInTheDocument()
})
})
@@ -1,10 +1,8 @@
import { useParams } from 'common'
import { ExternalLink } from 'lucide-react'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useCallback } from 'react'
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { subscriptionHasHipaaAddon } from '../../Billing/Subscription/Subscription.utils'
import { type SqlSnippetSource } from '../../SQLEditor/querySource'
import { buildDebugPromptText } from '../../SQLEditor/SQLEditor.utils'
import { useCreateChat } from '../hooks'
@@ -12,11 +10,8 @@ import { type QueryResult } from '../types'
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
import CopyButton from '@/components/ui/CopyButton'
import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink'
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
import { getSqlErrorLines } from '@/data/sql/utils'
import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { DOCS_URL, IS_PLATFORM } from '@/lib/constants'
import { DOCS_URL } from '@/lib/constants'
export const QueryResultError = ({
error,
@@ -34,26 +29,6 @@ export const QueryResultError = ({
* into that conversation's composer instead of abandoning it for a new chat. */
onDebug?: (prompt: string) => void
}) => {
const { ref } = useParams()
const { data: org } = useSelectedOrganizationQuery()
const { data: subscription, isSuccess: isSubscriptionResolved } = useOrgSubscriptionQuery({
orgSlug: org?.slug,
})
const { data: projectSettings, isSuccess: isProjectSettingsResolved } = useProjectSettingsV2Query(
{
projectRef: ref,
}
)
const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) && projectSettings?.is_sensitive
// Default deny until both eligibility queries have actually succeeded - a disabled or
// failed query also reports isLoading: false, so isLoading can't tell "confirmed no
// addon" apart from "don't know yet", and the assistant sends the SQL and error to an
// LLM. Self-hosted has no HIPAA concept at all (subscriptionHasHipaaAddon short-circuits
// to false there), so there's nothing to wait on outside of platform.
const isCheckingHipaaEligibility =
IS_PLATFORM && (!isSubscriptionResolved || !isProjectSettingsResolved)
const { createChat, isCreating } = useCreateChat()
const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
@@ -175,7 +150,7 @@ export const QueryResultError = ({
</TooltipContent>
</Tooltip>
)}
{!hasHipaaAddon && !isCheckingHipaaEligibility && canDebug && (
{canDebug && (
<AiAssistantDropdown
telemetrySource="sql_debug"
label="Debug with Assistant"
@@ -4,8 +4,10 @@ import { FormField, RadioGroup, RadioGroupItem } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { OptInToOpenAIToggle } from './OptInToOpenAIToggle'
import { InlineLink } from '@/components/ui/InlineLink'
import { AIOptInFormValues } from '@/hooks/forms/useAIOptInForm'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { DOCS_URL } from '@/lib/constants'
interface AIOptInLevelSelectorProps {
control: Control<AIOptInFormValues>
@@ -89,7 +91,12 @@ export const AIOptInLevelSelector = ({
<p>
For organizations with HIPAA compliance enabled in their Supabase configuration, any
consented information will only be shared with third-party AI providers with whom
Supabase has established a Business Associate Agreement (BAA).
Supabase has established a Business Associate Agreement (BAA). Don't input personal data
unless you've{' '}
<InlineLink href={`${DOCS_URL}/guides/deployment/shared-responsibility-model`}>
obtained consent
</InlineLink>{' '}
from the individuals it relates to.
</p>
<OptInToOpenAIToggle />
</div>
@@ -9,6 +9,7 @@ import {
} from 'ui'
import { InlineLink } from '@/components/ui/InlineLink'
import { DOCS_URL } from '@/lib/constants'
export const OptInToOpenAIToggle = () => {
return (
@@ -42,7 +43,12 @@ export const OptInToOpenAIToggle = () => {
<p>
For organizations with HIPAA compliance enabled in their Supabase configuration, any
consented information will only be shared with third-party AI providers with whom
Supabase has established a Business Associate Agreement (BAA).
Supabase has established a Business Associate Agreement (BAA). Don't input personal data
unless you've{' '}
<InlineLink href={`${DOCS_URL}/guides/deployment/shared-responsibility-model`}>
obtained consent
</InlineLink>{' '}
from the individuals it relates to.
</p>
<p>
@@ -63,8 +63,7 @@ const RenameQueryForm = ({ snippet, onCancel, onComplete }: RenameQueryFormProps
const tabsSnap = useTabsStateSnapshot()
const isSQLSnippet = snippet.type === 'sql'
// Orgs on HIPAA plans or that have disabled AI should not have access to Supabase AI
const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel()
const { aiOptInLevel } = useOrgAiOptInLevel()
const isAiOptedOut = aiOptInLevel === 'disabled'
const { id, name, description } = snippet
@@ -180,19 +179,15 @@ const RenameQueryForm = ({ snippet, onCancel, onComplete }: RenameQueryFormProps
<ButtonTooltip
onClick={() => generateTitle()}
size="tiny"
disabled={
isTitleGenerationLoading || !isApiKeySet || isHipaaProjectDisallowed || isAiOptedOut
}
disabled={isTitleGenerationLoading || !isApiKeySet || isAiOptedOut}
tooltip={{
content: {
side: 'bottom',
text: isHipaaProjectDisallowed
? 'This feature is not available for HIPAA projects.'
: isAiOptedOut
? 'Your organization has opted out of AI features.'
: isApiKeySet
? undefined
: 'Add your "OPENAI_API_KEY" to your environment variables to use this feature.',
text: isAiOptedOut
? 'Your organization has opted out of AI features.'
: isApiKeySet
? undefined
: 'Add your "OPENAI_API_KEY" to your environment variables to use this feature.',
},
}}
>
@@ -263,10 +263,9 @@ export function resolveConnectionString(
/**
* Whether a query run should lazily kick off AI title generation for the
* snippet: only when the org has AI enabled (not disabled/HIPAA — which would
* silently forward the query to the AI provider without consent), the
* snippet still has its placeholder name, and we're running on the hosted
* platform.
* snippet: only when the org has AI enabled (a disabled org would silently
* forward the query to the AI provider without consent), the snippet still
* has its placeholder name, and we're running on the hosted platform.
*/
export function shouldAutoGenerateTitle({
aiOptInLevel,
@@ -4,15 +4,11 @@ import { parseAsBoolean, useQueryState } from 'nuqs'
import { forwardRef } from 'react'
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { subscriptionHasHipaaAddon } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
import CopyButton from '@/components/ui/CopyButton'
import { DataGridResults } from '@/components/ui/DataGridResults'
import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink'
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
import { getSqlErrorLines } from '@/data/sql/utils'
import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { DOCS_URL } from '@/lib/constants'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
@@ -30,16 +26,10 @@ export const UtilityTabResults = forwardRef<HTMLDivElement, UtilityTabResultsPro
({ id, isExecuting, isDisabled, isDebugging, onDebug, buildDebugPrompt }) => {
const { ref } = useParams()
const state = useDatabaseSelectorStateSnapshot()
const { data: organization } = useSelectedOrganizationQuery()
const sessionSnap = useSqlEditorSessionSnapshot()
const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
const result = sessionSnap.results[id]?.[0]
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
// Customers on HIPAA plans should not have access to Supabase AI
const { data: projectSettings } = useProjectSettingsV2Query({ projectRef: ref })
const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) && projectSettings?.is_sensitive
const isTimeout =
result?.error?.message?.includes('canceling statement due to statement timeout') ||
@@ -154,16 +144,14 @@ export const UtilityTabResults = forwardRef<HTMLDivElement, UtilityTabResultsPro
</TooltipContent>
</Tooltip>
)}
{!hasHipaaAddon && (
<AiAssistantDropdown
label="Debug with Assistant"
buildPrompt={buildDebugPrompt}
onOpenAssistant={onDebug}
telemetrySource="sql_debug"
disabled={!!isDisabled || isDebugging}
loading={isDebugging}
/>
)}
<AiAssistantDropdown
label="Debug with Assistant"
buildPrompt={buildDebugPrompt}
onOpenAssistant={onDebug}
telemetrySource="sql_debug"
disabled={!!isDisabled || isDebugging}
loading={isDebugging}
/>
</div>
</div>
</div>
@@ -68,7 +68,7 @@ export const ComplianceConfig = () => {
return (
<PageSection id="compliance-configuration">
<PageSectionMeta>
<div className="flex flex-col gap-3 @lg:flex-row @lg:items-center @lg:justify-between">
<div className="w-full flex flex-col gap-3 @lg:flex-row @lg:items-center @lg:justify-between">
<PageSectionSummary>
<PageSectionTitle>High Compliance Configuration</PageSectionTitle>
<PageSectionDescription>
@@ -50,7 +50,6 @@ const defaultProps = {
onCloseAssistant: vi.fn(),
showMetadataWarning: false,
updatedOptInSinceMCP: true,
isHipaaProjectDisallowed: false,
aiOptInLevel: 'full',
}
@@ -39,7 +39,6 @@ interface AIAssistantHeaderProps {
onCloseAssistant: () => void
showMetadataWarning: boolean
updatedOptInSinceMCP: boolean
isHipaaProjectDisallowed: boolean
aiOptInLevel: 'disabled' | 'schema' | 'full' | string | undefined
}
@@ -50,7 +49,6 @@ export const AIAssistantHeader = ({
onCloseAssistant,
showMetadataWarning,
updatedOptInSinceMCP,
isHipaaProjectDisallowed,
aiOptInLevel,
}: AIAssistantHeaderProps) => {
const { openChat } = useCreateChat()
@@ -244,7 +242,6 @@ export const AIAssistantHeader = ({
onVisibleChange={setIsOptInModalOpen}
showMetadataWarning={showMetadataWarning}
updatedOptInSinceMCP={updatedOptInSinceMCP}
isHipaaProjectDisallowed={isHipaaProjectDisallowed}
aiOptInLevel={aiOptInLevel}
/>
</div>
@@ -8,7 +8,6 @@ interface AIAssistantMetadataWarningProps {
onVisibleChange: (visible: boolean) => void
showMetadataWarning: boolean
updatedOptInSinceMCP: boolean
isHipaaProjectDisallowed: boolean
aiOptInLevel: 'disabled' | 'schema' | 'full' | string | undefined
}
@@ -17,7 +16,6 @@ export const AIAssistantMetadataWarning = ({
onVisibleChange,
showMetadataWarning,
updatedOptInSinceMCP,
isHipaaProjectDisallowed,
aiOptInLevel,
}: AIAssistantMetadataWarningProps) => (
<>
@@ -27,30 +25,24 @@ export const AIAssistantMetadataWarning = ({
title={
!updatedOptInSinceMCP
? 'The Assistant has just been updated to help you better!'
: isHipaaProjectDisallowed
? 'Project metadata is not shared due to HIPAA'
: aiOptInLevel === 'disabled'
? 'Project metadata is currently not shared'
: 'Limited metadata is shared to the Assistant'
: aiOptInLevel === 'disabled'
? 'Project metadata is currently not shared'
: 'Limited metadata is shared to the Assistant'
}
description={
!updatedOptInSinceMCP
? 'You may now opt-in to share schema metadata and even logs for better results'
: isHipaaProjectDisallowed
? 'Your organization has the HIPAA addon and will not send project metadata with your prompts for projects marked as HIPAA.'
: aiOptInLevel === 'disabled'
? 'The Assistant can provide better answers if you opt-in to share schema metadata.'
: aiOptInLevel === 'schema'
? 'Sharing query data in addition to schema can further improve responses. Update AI settings to enable this.'
: ''
: aiOptInLevel === 'disabled'
? 'The Assistant can provide better answers if you opt-in to share schema metadata.'
: aiOptInLevel === 'schema'
? 'Sharing query data in addition to schema can further improve responses. Update AI settings to enable this.'
: ''
}
className="border-0 border-b rounded-none bg-background"
>
{!isHipaaProjectDisallowed && (
<Button className="w-fit mt-4" onClick={() => onVisibleChange(true)}>
Permission settings
</Button>
)}
<Button className="w-fit mt-4" onClick={() => onVisibleChange(true)}>
Permission settings
</Button>
</Admonition>
)}
<AIOptInModal visible={visible} onCancel={() => onVisibleChange(false)} />
@@ -51,7 +51,6 @@ export interface AssistantChatHeaderProps {
isChatLoading: boolean
showMetadataWarning: boolean
updatedOptInSinceMCP: boolean
isHipaaProjectDisallowed: boolean
aiOptInLevel: 'disabled' | 'schema' | 'full' | string | undefined
}
@@ -110,7 +109,7 @@ export const AssistantChat = ({
const inputRef = useRef<HTMLTextAreaElement>(null)
const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel()
const { aiOptInLevel } = useOrgAiOptInLevel()
// Whether attached queries are sent at all. One definition, shared by the chat form
// (which folds them into the message text) and the message metadata (which states
// whether any of them was a logs query), so the two can't disagree.
@@ -559,7 +558,6 @@ export const AssistantChat = ({
isChatLoading,
showMetadataWarning,
updatedOptInSinceMCP,
isHipaaProjectDisallowed,
aiOptInLevel,
})}
{hasMessages ? (
@@ -34,8 +34,7 @@ export const SaveSnippetDialog = ({ open, sql, onOpenChange, onSave }: SaveSnipp
const isApiKeySet = !!check?.hasKey
// Orgs on HIPAA plans or that have disabled AI should not have access to Supabase AI
const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel()
const { aiOptInLevel } = useOrgAiOptInLevel()
const isAiOptedOut = aiOptInLevel === 'disabled'
const { mutate: generateTitle, isPending: isGenerating } = useSqlTitleGenerateMutation({
@@ -78,18 +77,16 @@ export const SaveSnippetDialog = ({ open, sql, onOpenChange, onSave }: SaveSnipp
<div className="flex justify-end">
<ButtonTooltip
size="tiny"
disabled={isGenerating || !isApiKeySet || isHipaaProjectDisallowed || isAiOptedOut}
disabled={isGenerating || !isApiKeySet || isAiOptedOut}
onClick={() => generateTitle({ sql })}
tooltip={{
content: {
side: 'bottom',
text: isHipaaProjectDisallowed
? 'This feature is not available for HIPAA projects.'
: isAiOptedOut
? 'Your organization has opted out of AI features.'
: isApiKeySet
? undefined
: 'Add your "OPENAI_API_KEY" to your environment variables to use this feature.',
text: isAiOptedOut
? 'Your organization has opted out of AI features.'
: isApiKeySet
? undefined
: 'Add your "OPENAI_API_KEY" to your environment variables to use this feature.',
},
}}
>
+2 -24
View File
@@ -1,10 +1,6 @@
import { z } from 'zod'
import { subscriptionHasHipaaAddon } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { IS_PLATFORM, OPT_IN_TAGS } from '@/lib/constants'
export const aiOptInLevelSchema = z.enum([
@@ -49,9 +45,7 @@ export function useOrgOptedIntoAi(): boolean {
export function useOrgAiOptInLevel(): {
aiOptInLevel: AiOptInLevel
includeSchemaMetadata: boolean
isHipaaProjectDisallowed: boolean
} {
const { data: selectedProject } = useSelectedProjectQuery()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
// [Joshen] Default to disabled until migration to clean up existing opt in tags are completed
@@ -60,25 +54,9 @@ export function useOrgAiOptInLevel(): {
const level = getAiOptInLevel(optInTags)
const isOptedIntoAI = level !== 'disabled'
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganization?.slug })
const hasHipaaAddon = subscriptionHasHipaaAddon(subscription)
const { data: projectSettings } = useProjectSettingsV2Query({ projectRef: selectedProject?.ref })
const isProjectSensitive = !!projectSettings?.is_sensitive
const preventProjectFromUsingAI = hasHipaaAddon && isProjectSensitive
// [Joshen] For CLI / self-host, we'd default to 'schema' as opt in level
const aiOptInLevel = !IS_PLATFORM
? 'schema'
: (isOptedIntoAI && !selectedProject) || (isOptedIntoAI && !preventProjectFromUsingAI)
? level
: 'disabled'
const aiOptInLevel = !IS_PLATFORM ? 'schema' : isOptedIntoAI ? level : 'disabled'
const includeSchemaMetadata = !IS_PLATFORM || aiOptInLevel !== 'disabled'
return {
aiOptInLevel,
includeSchemaMetadata,
isHipaaProjectDisallowed: preventProjectFromUsingAI,
}
return { aiOptInLevel, includeSchemaMetadata }
}
+12 -42
View File
@@ -93,7 +93,6 @@ describe('getAIDetails', () => {
planId: 'pro',
region: 'us-east-1',
isSensitive: false,
isRestrictedByHipaa: false,
})
})
@@ -233,49 +232,20 @@ describe('getAIDetails', () => {
expect(result.region).toBeUndefined()
})
describe('HIPAA organizations', () => {
beforeEach(() => {
mockSubscriptionHasHipaaAddon.mockReturnValue(true)
mockGetAiOptInLevel.mockReturnValue('schema_and_log_and_data')
it('keeps the opt-in level for a sensitive project in a HIPAA org', async () => {
mockSubscriptionHasHipaaAddon.mockReturnValue(true)
mockGetAiOptInLevel.mockReturnValue('schema_and_log_and_data')
mockGetProjectSettings.mockResolvedValue({ is_sensitive: true })
const result = await getAIDetails({
orgSlug: ORG_SLUG,
projectRef: PROJECT_REF,
authorization: AUTH,
})
it('disables the opt-in level for a sensitive project', async () => {
mockGetProjectSettings.mockResolvedValue({ is_sensitive: true })
const result = await getAIDetails({
orgSlug: ORG_SLUG,
projectRef: PROJECT_REF,
authorization: AUTH,
})
expect(result.aiOptInLevel).toBe('disabled')
expect(result.hasHipaaAddon).toBe(true)
expect(result.isRestrictedByHipaa).toBe(true)
})
it('disables the opt-in level when project sensitivity is unknown', async () => {
mockGetProjectSettings.mockResolvedValue(undefined)
const result = await getAIDetails({
orgSlug: ORG_SLUG,
projectRef: PROJECT_REF,
authorization: AUTH,
})
expect(result.aiOptInLevel).toBe('disabled')
})
it('keeps the opt-in level for a project explicitly marked not sensitive', async () => {
mockGetProjectSettings.mockResolvedValue({ is_sensitive: false })
const result = await getAIDetails({
orgSlug: ORG_SLUG,
projectRef: PROJECT_REF,
authorization: AUTH,
})
expect(result.aiOptInLevel).toBe('schema_and_log_and_data')
})
expect(result.aiOptInLevel).toBe('schema_and_log_and_data')
expect(result.hasHipaaAddon).toBe(true)
expect(result.isSensitive).toBe(true)
})
it('keeps the opt-in level for a sensitive project outside a HIPAA org', async () => {
+1 -8
View File
@@ -15,8 +15,6 @@ export type AIDetails = {
planId: string | undefined
region: string | undefined
isSensitive: boolean | null | undefined
// True when HIPAA forced the opt-in level to `disabled`, rather than the org choosing it.
isRestrictedByHipaa: boolean
}
// Resolves the AI opt-in level, model access and tracing inputs for one org/project pair.
@@ -63,17 +61,13 @@ export const getAIDetails = async ({
planId: undefined,
region,
isSensitive,
isRestrictedByHipaa: false,
}
}
const hasHipaaAddon = subscriptionHasHipaaAddon(subscription)
// Mirrors the client-side gate in useOrgAiOptInLevel, which had no server-side equivalent
const isRestrictedByHipaa = hasHipaaAddon && isSensitive !== false
return {
aiOptInLevel: isRestrictedByHipaa ? 'disabled' : getAiOptInLevel(selectedOrg.opt_in_tags),
aiOptInLevel: getAiOptInLevel(selectedOrg.opt_in_tags),
hasAccessToAdvanceModel: advanceModelAccess.hasAccess,
hasHipaaAddon,
orgId: selectedOrg.id,
@@ -81,6 +75,5 @@ export const getAIDetails = async ({
planId: selectedOrg.plan.id,
region,
isSensitive,
isRestrictedByHipaa,
}
}
+7 -25
View File
@@ -38,7 +38,7 @@ describe('tool allowance by opt-in level', () => {
query_logs: { execute: vitest.fn().mockResolvedValue({ status: 'success' }) },
} as unknown as ToolSet
const filtered = filterToolsByOptInLevel(mockTools, optInLevel as any, false)
const filtered = filterToolsByOptInLevel(mockTools, optInLevel as any)
const allowedTools: string[] = []
Object.entries(filtered).forEach(([toolName, tool]) => {
@@ -163,14 +163,14 @@ describe('filterToolsByOptInLevel', () => {
}
it('should filter out unknown tools entirely', async () => {
const tools = filterToolsByOptInLevel(mockTools, 'disabled', false)
const tools = filterToolsByOptInLevel(mockTools, 'disabled')
// Unknown tools should be completely filtered out (not present in result)
expect(tools).not.toHaveProperty('some_other_tool')
})
it('should always allow UI tools regardless of opt-in level', async () => {
const tools = filterToolsByOptInLevel(mockTools, 'disabled', false)
const tools = filterToolsByOptInLevel(mockTools, 'disabled')
expect(tools).toHaveProperty('execute_sql')
expect(tools).toHaveProperty('deploy_edge_function')
@@ -189,7 +189,7 @@ describe('filterToolsByOptInLevel', () => {
})
it('should stub all managed tools for disabled opt-in level', async () => {
const tools = filterToolsByOptInLevel(mockTools, 'disabled', false)
const tools = filterToolsByOptInLevel(mockTools, 'disabled')
await expectStubsFor(tools, [
'list_tables',
@@ -203,7 +203,7 @@ describe('filterToolsByOptInLevel', () => {
})
it('should stub log tools for schema opt-in level', async () => {
const tools = filterToolsByOptInLevel(mockTools, 'schema', false)
const tools = filterToolsByOptInLevel(mockTools, 'schema')
await expectStubsFor(tools, ['get_advisors', 'query_logs'])
})
@@ -211,7 +211,7 @@ describe('filterToolsByOptInLevel', () => {
// No execute_sql tool, so nothing additional to stub for schema_and_log opt-in level
it('should not stub any tools for schema_and_log_and_data opt-in level', async () => {
const tools = filterToolsByOptInLevel(mockTools, 'schema_and_log_and_data', false)
const tools = filterToolsByOptInLevel(mockTools, 'schema_and_log_and_data')
await expectStubsFor(tools, [])
})
@@ -226,7 +226,7 @@ describe('createPrivacyMessageTool', () => {
toModelOutput: vitest.fn(),
}
const privacyTool = createPrivacyMessageTool(originalTool, false)
const privacyTool = createPrivacyMessageTool(originalTool)
expect(privacyTool.description).toContain('Original description')
expect(privacyTool.description).toContain('Requires opting in')
@@ -236,24 +236,6 @@ describe('createPrivacyMessageTool', () => {
expect(result.status).toContain("You don't have permission to use this tool")
expect(result.status).toContain('third-party AI providers')
})
it('uses HIPAA copy when the project is HIPAA-restricted', async () => {
const originalTool = {
description: 'Original description',
inputSchema: z.object({}),
execute: vitest.fn(),
}
const hipaaTool = createPrivacyMessageTool(originalTool, true)
const optInTool = createPrivacyMessageTool(originalTool, false)
const hipaaStatus = (await hipaaTool.execute({}, {})).status
const optInStatus = (await optInTool.execute({}, {})).status
expect(hipaaStatus).toContain('HIPAA')
expect(hipaaStatus).not.toEqual(optInStatus)
expect(hipaaTool.description).not.toEqual(optInTool.description)
})
})
describe('toolSetValidationSchema', () => {
+7 -16
View File
@@ -167,16 +167,11 @@ function isToolAllowed(toolName: string, aiOptInLevel: AiOptInLevel): boolean {
* Names no provider. Studio switches inference providers, and a stale name here becomes a
* false privacy claim shown to users.
*/
export function createPrivacyMessageTool(
toolInstance: Tool<any, any>,
isRestrictedByHipaa: boolean
) {
const privacyMessage = isRestrictedByHipaa
? "You don't have permission to use this tool. This project is configured as High Compliance and your organization has the HIPAA add-on, so Supabase does not send project metadata with your prompts. The user cannot change this in their organization's AI settings, so continue without project metadata rather than asking them to opt in."
: "You don't have permission to use this tool. This is an organization-wide setting requiring you to opt-in. Please choose your preferred data sharing level in your organization's settings. By default, no data is shared. Granting permission allows Supabase to send information (like schema, logs, or data, depending on your chosen level) to third-party AI providers solely to generate responses."
const condensedPrivacyMessage = isRestrictedByHipaa
? 'Unavailable because this project is configured as High Compliance, which prevents project metadata from being shared. This cannot be changed in organization settings.'
: 'Requires opting in to sharing data with third-party AI providers. You can opt in via organization settings.'
export function createPrivacyMessageTool(toolInstance: Tool<any, any>) {
const privacyMessage =
"You don't have permission to use this tool. This is an organization-wide setting requiring you to opt-in. Please choose your preferred data sharing level in your organization's settings. By default, no data is shared. Granting permission allows Supabase to send information (like schema, logs, or data, depending on your chosen level) to third-party AI providers solely to generate responses."
const condensedPrivacyMessage =
'Requires opting in to sharing data with third-party AI providers. You can opt in via organization settings.'
const toolDescription = toolInstance.description
const description =
typeof toolDescription === 'function'
@@ -197,11 +192,7 @@ export function createPrivacyMessageTool(
/**
* Filter tools based on the AI opt-in level
*/
export function filterToolsByOptInLevel(
tools: ToolSet,
aiOptInLevel: AiOptInLevel,
isRestrictedByHipaa: boolean
): ToolSet {
export function filterToolsByOptInLevel(tools: ToolSet, aiOptInLevel: AiOptInLevel): ToolSet {
return Object.fromEntries(
Object.entries(tools)
.filter(([toolName]) => TOOL_CATEGORY_MAP[toolName] !== undefined)
@@ -211,7 +202,7 @@ export function filterToolsByOptInLevel(
}
// If the tool is not allowed, provide a stub that returns a privacy message
return [toolName, createPrivacyMessageTool(toolInstance, isRestrictedByHipaa)]
return [toolName, createPrivacyMessageTool(toolInstance)]
})
)
}
-2
View File
@@ -18,7 +18,6 @@ const BASE_PARAMS = {
connectionString: 'postgresql://localhost',
authorization: 'Bearer token',
aiOptInLevel: 'schema_and_log_and_data' as const,
isRestrictedByHipaa: false,
accessToken: 'access-token',
baseUrl: 'https://supabase.com/dashboard',
signal: new AbortController().signal,
@@ -40,7 +39,6 @@ describe('ai/tools getTools', () => {
accessToken: BASE_PARAMS.accessToken,
projectRef: BASE_PARAMS.projectRef,
aiOptInLevel: BASE_PARAMS.aiOptInLevel,
isRestrictedByHipaa: BASE_PARAMS.isRestrictedByHipaa,
signal: BASE_PARAMS.signal,
})
expect(tools).toHaveProperty('studio_tool')
+1 -9
View File
@@ -17,7 +17,6 @@ export const getTools = async ({
connectionString,
authorization,
aiOptInLevel,
isRestrictedByHipaa,
accessToken,
baseUrl,
supportMode,
@@ -28,8 +27,6 @@ export const getTools = async ({
connectionString: string
authorization?: string
aiOptInLevel: AiOptInLevel
// Only changes the blocked-tool wording.
isRestrictedByHipaa: boolean
accessToken?: string
baseUrl?: string
supportMode?: boolean
@@ -66,7 +63,6 @@ export const getTools = async ({
accessToken,
projectRef,
aiOptInLevel,
isRestrictedByHipaa,
signal,
})
} catch (error) {
@@ -95,11 +91,7 @@ export const getTools = async ({
// Filter all tools based on the (potentially modified) AI opt-in level
const toolsWithSupport = supportMode ? { ...tools, ...getSupportLifecycleTools() } : tools
const filteredTools: ToolSet = filterToolsByOptInLevel(
toolsWithSupport,
aiOptInLevel,
isRestrictedByHipaa
)
const filteredTools: ToolSet = filterToolsByOptInLevel(toolsWithSupport, aiOptInLevel)
return filteredTools
}
@@ -11,7 +11,6 @@ const BASE_PARAMS = {
accessToken: 'token',
projectRef: 'abcdefghijklmnopqrst',
aiOptInLevel: 'schema_and_log_and_data' as const,
isRestrictedByHipaa: false,
// A fresh, non-aborted signal by default; lifecycle tests override it
signal: new AbortController().signal,
}
+1 -7
View File
@@ -41,13 +41,11 @@ export const getMcpTools = async ({
accessToken,
projectRef,
aiOptInLevel,
isRestrictedByHipaa,
signal,
}: {
accessToken: string
projectRef: string
aiOptInLevel: AiOptInLevel
isRestrictedByHipaa: boolean
// Required: the remote client holds an HTTP connection that must be torn down
// when the request ends. The caller owns that lifecycle via this signal.
signal: AbortSignal
@@ -101,11 +99,7 @@ export const getMcpTools = async ({
// write/destructive tools (apply_migration, create_branch, ...) from reaching
// the assistant. `read_only` is defense-in-depth (those tools throw at
// runtime). Do not remove this filter on the assumption `read_only` suffices.
const allowedMcpTools = filterToolsByOptInLevel(
availableMcpTools,
aiOptInLevel,
isRestrictedByHipaa
)
const allowedMcpTools = filterToolsByOptInLevel(availableMcpTools, aiOptInLevel)
// Remove UI-executed tools handled locally
const filteredMcpTools: ToolSet = { ...allowedMcpTools }
@@ -120,7 +120,6 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
const includesLogsSnippets = messagesIncludeLogsSnippets(messages)
let aiOptInLevel: AiOptInLevel = 'disabled'
let isRestrictedByHipaa = false
let hasAccessToAdvanceModel = false
let orgHasHipaaAddon: boolean | undefined
let projectIsSensitive: boolean | null | undefined
@@ -139,7 +138,6 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
const aiDetails = await getAIDetails({ orgSlug: rawOrgSlug, projectRef, authorization })
aiOptInLevel = aiDetails.aiOptInLevel
isRestrictedByHipaa = aiDetails.isRestrictedByHipaa
hasAccessToAdvanceModel = aiDetails.hasAccessToAdvanceModel
orgHasHipaaAddon = aiDetails.hasHipaaAddon
orgId = aiDetails.orgId
@@ -189,7 +187,6 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
connectionString,
authorization,
aiOptInLevel,
isRestrictedByHipaa,
accessToken,
baseUrl: getURL(),
supportMode,
-3
View File
@@ -73,7 +73,6 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse, clai
const { tableName, schema, columns = [], projectRef, connectionString, orgSlug, message } = data
let aiOptInLevel: AiOptInLevel = 'disabled'
let isRestrictedByHipaa = false
if (!IS_PLATFORM) {
aiOptInLevel = 'schema'
@@ -84,7 +83,6 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse, clai
const aiDetails = await getAIDetails({ orgSlug, projectRef, authorization })
aiOptInLevel = aiDetails.aiOptInLevel
isRestrictedByHipaa = aiDetails.isRestrictedByHipaa
} catch (error) {
return res.status(400).json({
error: 'There was an error fetching your organization details',
@@ -119,7 +117,6 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse, clai
connectionString,
authorization,
aiOptInLevel,
isRestrictedByHipaa,
accessToken,
isExplorerEnabled: explorerEnabled,
signal: toolsAbortController.signal,