From 4bb36b944fe3bb5cc4825e9653e694c883f0f21b Mon Sep 17 00:00:00 2001
From: Matt Rossman <22670878+mattrossman@users.noreply.github.com>
Date: Fri, 18 Sep 2026 08:21:50 -0400
Subject: [PATCH] feat(studio): let High Compliance projects opt-in to
Assistant data access (#50548)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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).
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 |
|--------|--------|
| | |
Closes AI-1153
## 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.
---------
Co-authored-by: Joshen Lim
---
.../Explorer/ExplorerChatToolbar.tsx | 2 -
.../QueryResultError.selfhosted.test.tsx | 81 -------------------
.../QueryEditor/QueryResultError.test.tsx | 49 -----------
.../Explorer/QueryEditor/QueryResultError.tsx | 29 +------
.../GeneralSettings/AIOptInLevelSelector.tsx | 9 ++-
.../GeneralSettings/OptInToOpenAIToggle.tsx | 8 +-
.../interfaces/SQLEditor/RenameQueryModal.tsx | 19 ++---
.../interfaces/SQLEditor/SQLEditor.utils.ts | 7 +-
.../UtilityPanel/UtilityTabResults.tsx | 28 ++-----
.../ProjectComplianceMode.tsx | 2 +-
.../AIAssistantHeader.test.tsx | 1 -
.../ui/AIAssistantPanel/AIAssistantHeader.tsx | 3 -
.../AIAssistantMetadataWarning.tsx | 30 +++----
.../ui/AIAssistantPanel/AssistantChat.tsx | 4 +-
.../ui/EditorPanel/SaveSnippetDialog.tsx | 17 ++--
apps/studio/hooks/misc/useOrgOptedIntoAi.ts | 26 +-----
apps/studio/lib/ai/ai-details.test.ts | 54 +++----------
apps/studio/lib/ai/ai-details.ts | 9 +--
apps/studio/lib/ai/tool-filter.test.ts | 32 ++------
apps/studio/lib/ai/tool-filter.ts | 23 ++----
apps/studio/lib/ai/tools/index.test.ts | 2 -
apps/studio/lib/ai/tools/index.ts | 10 +--
apps/studio/lib/ai/tools/mcp-tools.test.ts | 1 -
apps/studio/lib/ai/tools/mcp-tools.ts | 8 +-
apps/studio/pages/api/ai/sql/generate-v4.ts | 3 -
apps/studio/pages/api/ai/sql/policy.ts | 3 -
26 files changed, 86 insertions(+), 374 deletions(-)
delete mode 100644 apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.selfhosted.test.tsx
diff --git a/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx b/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx
index 0c2511aa07f..07748bfc964 100644
--- a/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx
+++ b/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx
@@ -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}
/>
diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.selfhosted.test.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.selfhosted.test.tsx
deleted file mode 100644
index 354291b5ed0..00000000000
--- a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.selfhosted.test.tsx
+++ /dev/null
@@ -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>('@/lib/constants')
- return { ...actual, IS_PLATFORM: false }
-})
-
-vi.mock('common', async (importOriginal) => {
- const actual = await importOriginal()
- 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(
-
- )
-
- expect(screen.getByRole('button', { name: 'Debug with Assistant' })).toBeInTheDocument()
- })
-})
diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.test.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.test.tsx
index 7b7f1732bb1..3a1bbe8b0f8 100644
--- a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.test.tsx
+++ b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.test.tsx
@@ -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>('@/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(
-
- )
-
- 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(
-
- )
-
- expect(screen.queryByRole('button', { name: 'Debug with Assistant' })).not.toBeInTheDocument()
- })
})
diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx
index 0ce4fc3af25..be46a183770 100644
--- a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx
+++ b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx
@@ -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 = ({
)}
- {!hasHipaaAddon && !isCheckingHipaaEligibility && canDebug && (
+ {canDebug && (
@@ -89,7 +91,12 @@ export const AIOptInLevelSelector = ({
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{' '}
+
+ obtained consent
+ {' '}
+ from the individuals it relates to.
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{' '}
+
+ obtained consent
+ {' '}
+ from the individuals it relates to.
diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
index 98d9f54786d..4a0e55a4230 100644
--- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
@@ -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
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.',
},
}}
>
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts
index 406ac4419ca..b8bcd369c9c 100644
--- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts
+++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts
@@ -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,
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx
index 077c86eaa5d..1f24875a615 100644
--- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx
@@ -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 {
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
)}
- {!hasHipaaAddon && (
-
- )}
+
diff --git a/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx b/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx
index d4ee5778de4..a5213b1b05a 100644
--- a/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx
+++ b/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx
@@ -68,7 +68,7 @@ export const ComplianceConfig = () => {
return (
-