Files
supabase/apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx
Charis 3f97eeea5a feat(studio): extend safe SQL model to policy editor and related interfaces (#45560)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Refactor / security improvement

## What is the current behavior?

SQL fragments across Studio are built from plain `string` values with no
type-level distinction between developer-authored SQL, DB-sourced
identifiers, and user-typed or externally-influenced content.

## What is the new behavior?

Extends the safe SQL model to additional Studio interfaces, using
`SafeSqlFragment`, `safeSql`, `ident()`, `literal()`, `untrustedSql()`,
and `acceptUntrustedSql()` from `@supabase/pg-meta/src/pg-format`:

- **Policy editor**: template constants typed as `SafeSqlFragment` via
`safeSql` tagged literals; Monaco editor `onInputChange` emits
`untrustedSql()`; `acceptUntrustedSql()` called only at the Save
gesture; roles selector emits a composed `SafeSqlFragment` via `ident()`
+ `joinSqlFragments()`
- **Auth hooks**: grant/revoke SQL statements use `ident()` for schema
and function names
- **Docs description editor**: `COMMENT ON` queries use `ident()` and
`literal()` for table/column/function names and values
- **Cron jobs**: `cron.schedule()` call and HTTP request builder use
`literal()` for all user-provided values
- **GraphQL linter CTA**: `REVOKE` statement uses `ident()` for schema,
table, and role
- **Storage public bucket warning**: `DROP POLICY` uses `ident()` for
policy name
- **View security autofix modal**: `ALTER VIEW` uses `ident()` for
schema and view name
- **API settings**: `CREATE SCHEMA` mutation uses `safeSql` tagged
literal
- **Database event trigger delete**: `DROP EVENT TRIGGER` uses `ident()`
for trigger name
- **Database queues query**: queue list query uses `safeSql` tagged
literal
- **Role impersonation**: function invocation SQL uses `ident()` and
`literal()`

## Manual testing checklist

- Authentication > Policies
- Authentication > Hooks
- Integrations > Queues
- Database > Event Triggers
- Integrations > Cron Jobs
- Table Editor > View entity security autofix
- API Settings > expose schema
- Linter > GraphQL exposure CTA
- Docs > table/column description editor
- Role impersonation (user impersonation panel)

## Additional context

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

* **Refactor**
* Replaced ad-hoc SQL string building with a safer, fragment-based SQL
construction across auth, policies, integrations, storage, and DB
operations to improve SQL safety while preserving behavior.

* **Bug Fixes / UX**
* Policy editor and code editor now propagate role and input changes
more reliably, improving editor responsiveness and policy handling
without UI changes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-05 12:02:52 -04:00

192 lines
6.7 KiB
TypeScript

import { ident, safeSql } from '@supabase/pg-meta/src/pg-format'
import { useQueryClient } from '@tanstack/react-query'
import { EyeOff, Lock } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { Badge, Button } from 'ui'
import { Admonition } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { InlineLink } from '@/components/ui/InlineLink'
import { lintKeys } from '@/data/lint/keys'
import { Lint } from '@/data/lint/lint-query'
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
const GRAPHQL_EXPOSURE_LINT_NAMES = [
'pg_graphql_anon_table_exposed',
'pg_graphql_authenticated_table_exposed',
] as const
export type GraphqlExposureLintName = (typeof GRAPHQL_EXPOSURE_LINT_NAMES)[number]
export const asGraphqlExposureLint = (
name: string | undefined | null
): GraphqlExposureLintName | null =>
!!name && (GRAPHQL_EXPOSURE_LINT_NAMES as readonly string[]).includes(name)
? (name as GraphqlExposureLintName)
: null
interface GraphqlExposureLintCTAProps {
lintName: GraphqlExposureLintName
projectRef: string
metadata: Lint['metadata']
onAfterAction?: () => void
}
const ROLE_BY_LINT: Record<GraphqlExposureLintName, 'anon' | 'authenticated'> = {
pg_graphql_anon_table_exposed: 'anon',
pg_graphql_authenticated_table_exposed: 'authenticated',
}
const AUDIENCE: Record<GraphqlExposureLintName, { lower: string; upper: string }> = {
pg_graphql_anon_table_exposed: { lower: 'anonymous users', upper: 'Anonymous users' },
pg_graphql_authenticated_table_exposed: { lower: 'signed-in users', upper: 'Signed-in users' },
}
const TRIGGER_LABEL: Record<GraphqlExposureLintName, string> = {
pg_graphql_anon_table_exposed: 'Remove access for anonymous users',
pg_graphql_authenticated_table_exposed: 'Remove access for signed-in users',
}
export const GraphqlExposureLintCTA = ({
lintName,
projectRef,
metadata,
onAfterAction,
}: GraphqlExposureLintCTAProps) => {
const { data: project } = useSelectedProjectQuery()
const queryClient = useQueryClient()
const [showConfirmRevoke, setShowConfirmRevoke] = useState(false)
const schema = metadata?.schema
const name = metadata?.name
const objectType = metadata?.type ?? 'object'
const role = ROLE_BY_LINT[lintName]
const audience = AUDIENCE[lintName]
const canAct = !!schema && !!name
const revokeSql =
schema && name
? safeSql`revoke all on ${ident(schema)}.${ident(name)} from ${ident(role)};`
: undefined
const { mutate: executeSql, isPending: isRevoking } = useExecuteSqlMutation({
onSuccess: async () => {
toast.success(
`Revoked access to ${schema}.${name} from ${role}. ${audience.upper} can no longer query this ${objectType} via GraphQL or Data API.`
)
setShowConfirmRevoke(false)
await queryClient.invalidateQueries({ queryKey: lintKeys.lint(projectRef) })
onAfterAction?.()
},
onError: (error) => {
toast.error(`Failed to revoke access: ${error.message}`)
},
})
const handleRevoke = () => {
if (!revokeSql) return
executeSql({
projectRef,
connectionString: project?.connectionString,
sql: revokeSql,
})
}
return (
<>
<Button type="primary" disabled={!canAct} onClick={() => setShowConfirmRevoke(true)}>
{TRIGGER_LABEL[lintName]}
</Button>
<ConfirmationModal
visible={showConfirmRevoke}
size="xlarge"
title={
canAct
? `Remove access to ${schema}.${name} for ${audience.lower}?`
: `Remove access for ${audience.lower}?`
}
confirmLabel="Remove access"
confirmLabelLoading="Removing access..."
cancelLabel="Cancel"
loading={isRevoking}
onCancel={() => setShowConfirmRevoke(false)}
onConfirm={handleRevoke}
>
<div className="text-sm text-foreground mb-6">
<p>This change affects both schema visibility and data access for {audience.lower}.</p>
<p>
Alternatively, you can{' '}
<InlineLink href={`/project/${projectRef}/database/extensions`}>
disable GraphQL
</InlineLink>{' '}
to remove schema visibility.
</p>
</div>
<div className="space-y-5">
<div className="flex gap-3">
<Lock className="text-foreground-light shrink-0 mt-0.5" size={20} strokeWidth={1.5} />
<div>
<div className="flex items-center gap-2">
<p className="text-sm text-foreground">Data API access removed</p>
<Badge variant="warning">Breaking change</Badge>
</div>
<p className="text-sm text-foreground-light mt-1">
{audience.upper} will no longer be able to read or write to this {objectType} via
Supabase APIs (GraphQL or Data API), even if RLS policies allow it.
</p>
</div>
</div>
<div className="flex gap-3">
<EyeOff className="text-foreground-light shrink-0 mt-0.5" size={20} strokeWidth={1.5} />
<div>
<p className="text-sm text-foreground">Schema hidden from GraphQL</p>
<p className="text-sm text-foreground-light mt-1">
This {objectType} will no longer appear in the GraphQL schema. {audience.upper}{' '}
won't be able to discover its name, columns, or relationships.
</p>
</div>
</div>
</div>
<Admonition
type="warning"
title="When to keep access"
description={`If your app needs ${audience.lower} to query this ${objectType}, keep access and ignore this warning. Be aware that this ${objectType}'s schema will remain visible via the GraphQL API.`}
className="mt-6"
/>
<p className="text-sm text-foreground-light mt-6">
The following statement will be executed:
</p>
<pre className="mt-2 px-3 py-2 rounded bg-surface-200 text-xs font-mono whitespace-pre-wrap break-all">
{revokeSql}
</pre>
</ConfirmationModal>
</>
)
}
export const GraphqlExposureCallout = ({ projectRef }: { projectRef: string }) => {
return (
<Admonition
type="default"
title="Why this appears"
description={
<p>
These warnings are triggered by GraphQL exposing your table schemas. If you're not using
GraphQL, disable it from the{' '}
<InlineLink href={`/project/${projectRef}/database/extensions`}>
Database extensions page
</InlineLink>
.
</p>
}
/>
)
}