mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
## 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 -->
192 lines
6.7 KiB
TypeScript
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>
|
|
}
|
|
/>
|
|
)
|
|
}
|