fix(studio): pass Authorization header to assistant list_policies tool (#50756)

<!-- ccr-slack-attribution -->
_Requested by **Saxon Fletcher** · [Slack
thread](https://supabase.slack.com/archives/C051L8U2EJF/p1789995309253479?thread_ts=1789995309.253479&cid=C051L8U2EJF)_

Resolves AI-1246

## Problem

**Before:** The Assistant's `list_policies` tool fails in about 70% of
traces. It only "succeeds" when the org has AI opt-in disabled, because
then it returns the privacy stub and never makes a request. When opt-in
is enabled, it runs the pg-meta query server-side with no
`Authorization` header, so the request is unauthenticated and fails. The
Assistant then falls back to `execute_sql`.

**After:** `list_policies` sends the caller's `Authorization` header,
the same way `execute_sql` in `studio-tools.ts` already does, so it
returns the project's RLS policies.

## Solution

`getTools` already receives `authorization` but didn't pass it to
`getSchemaTools`. This PR passes it through. `list_policies` builds `{
Authorization }` from it, and `getDatabasePolicies` gets an optional
`headersInit` argument that it forwards to `executeSql`, the same
pattern `getDatabaseFunctions` uses. Existing client-side callers of
`getDatabasePolicies` don't change.

Files: `lib/ai/tools/index.ts`, `lib/ai/tools/schema-tools.ts`,
`data/database-policies/database-policies-query.ts`, plus tests in
`lib/ai/tools/schema-tools.test.ts` (new) and
`lib/ai/tools/index.test.ts`.

## Review instructions

1. Read `schema-tools.ts` and compare it with the `authHeaders` handling
in `studio-tools.ts` (`execute_sql`).
2. On the preview, use an org with AI opt-in set to at least "schema"
and ask the Assistant to list the RLS policies on `public`.
`list_policies` should return the policies without falling back to
`execute_sql`.

Local gates (all passed):
- `pnpm typecheck` in `apps/studio` (next typegen + `tsc --noEmit`)
- `npx eslint` on touched files: 0 errors. The 2 warnings are on lines
this PR doesn't change.
- `npx vitest run lib/ai/tools/schema-tools.test.ts
lib/ai/tools/index.test.ts lib/ai/tool-filter.test.ts`: 24/24 passed. I
also ran the new header test against the old `schema-tools.ts` and it
failed, as expected.
- `SORT_IMPORTS=false npx prettier --config prettier.config.mjs --check`
on touched files

Follow-up, not in this PR: `getRlsKnowledge` in `fallback-tools.ts`
(self-hosted path) also calls `getDatabasePolicies` without headers,
even though a `headers` object is already in scope there.

## AI disclosure

Claude Code (agent) wrote this PR from the Slack request. @SaxonF (Saxon
Fletcher) is the accountable human owner. A human needs to review it
before merge.

## Checklist

- [x] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
- [ ] If I wrote a new docs topic or edited an existing topic, I used
the `/write-the-docs` or `/edit-the-docs` skill (N/A, no docs changes)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_0171Mk7SiKYLDDoAQvbDYfeK

---
_Generated by [Claude
Code](https://claude.ai/code/session_0171Mk7SiKYLDDoAQvbDYfeK)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-09-23 14:38:40 +08:00
1 parent e7e76ca0da
commit ec574f3a51
5 files changed
+82 -3

No files matched your search

@@ -16,7 +16,8 @@ type DatabasePoliciesVariables = {
export async function getDatabasePolicies(
{ projectRef, connectionString, schemas }: DatabasePoliciesVariables,
signal?: AbortSignal
signal?: AbortSignal,
headersInit?: HeadersInit
) {
if (!projectRef) throw new Error('projectRef is required')
@@ -28,7 +29,8 @@ export async function getDatabasePolicies(
sql,
queryKey: ['policies', schemas],
},
signal
signal,
headersInit
)
return result as PGPolicy[]
+11
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getTools } from './index'
import { getMcpTools } from './mcp-tools'
import { getSchemaTools } from './schema-tools'
vi.mock('common', () => ({ IS_PLATFORM: true }))
@@ -47,6 +48,16 @@ describe('ai/tools getTools', () => {
expect(tools).toHaveProperty('incident_tool')
})
it('passes authorization through to schema tools', async () => {
await getTools(BASE_PARAMS)
expect(getSchemaTools).toHaveBeenCalledWith({
projectRef: BASE_PARAMS.projectRef,
connectionString: BASE_PARAMS.connectionString,
authorization: BASE_PARAMS.authorization,
})
})
it('degrades gracefully to the remaining tools when remote MCP fetch fails', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(getMcpTools).mockRejectedValueOnce(new Error('remote MCP unreachable'))
+1
View File
@@ -75,6 +75,7 @@ export const getTools = async ({
...getSchemaTools({
projectRef,
connectionString,
authorization,
}),
...getReportTools({ projectRef, authorization }),
...(isExplorerEnabled
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getSchemaTools } from './schema-tools'
import { executeSql } from '@/data/sql/execute-sql-mutation'
vi.mock('@/data/sql/execute-sql-mutation', () => ({
executeSql: vi.fn(),
}))
const TOOL_CONTEXT = { toolCallId: 'test', messages: [], context: {} }
describe('ai/tools/schema-tools list_policies', () => {
beforeEach(() => {
vi.mocked(executeSql).mockReset()
vi.mocked(executeSql).mockResolvedValue({
result: [
{
name: 'Users can read own rows',
action: 'PERMISSIVE',
roles: ['authenticated'],
command: 'SELECT',
definition: '(auth.uid() = user_id)',
check: null,
},
],
})
})
it('forwards the Authorization header to executeSql', async () => {
const tools = getSchemaTools({
projectRef: 'abcdefghijklmnopqrst',
connectionString: 'encrypted',
authorization: 'Bearer token',
})
if (!tools.list_policies.execute) throw new Error('execute is undefined')
const result = await tools.list_policies.execute({ schemas: ['public'] }, TOOL_CONTEXT)
expect(executeSql).toHaveBeenCalledWith(
expect.objectContaining({
projectRef: 'abcdefghijklmnopqrst',
connectionString: 'encrypted',
}),
undefined,
{ Authorization: 'Bearer token' }
)
expect(result).toContain('Policy Name: "Users can read own rows"')
})
it('omits headers when no authorization is provided', async () => {
const tools = getSchemaTools({
projectRef: 'abcdefghijklmnopqrst',
connectionString: 'encrypted',
})
if (!tools.list_policies.execute) throw new Error('execute is undefined')
await tools.list_policies.execute({ schemas: ['public'] }, TOOL_CONTEXT)
expect(executeSql).toHaveBeenCalledWith(expect.any(Object), undefined, undefined)
})
})
+5 -1
View File
@@ -6,9 +6,11 @@ import { getDatabasePolicies } from '@/data/database-policies/database-policies-
export const getSchemaTools = ({
projectRef,
connectionString,
authorization,
}: {
projectRef: string
connectionString: string
authorization?: string
}) => ({
list_policies: tool({
description: 'Get existing RLS policies for a given schema',
@@ -22,13 +24,15 @@ export const getSchemaTools = ({
connectionString,
schemas,
},
undefined
undefined,
authorization ? { Authorization: authorization } : undefined
)
const formattedPolicies = data
.map(
(policy) => `
Policy Name: "${policy.name}"
Table: "${policy.table}"
Action: ${policy.action}
Roles: ${policy.roles.join(', ')}
Command: ${policy.command}