mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Feat/lints (#22003)
* Add lints, 2 * Add basic table * Handle ignoring lints * Add loading state * Cleanup * move utils * Cleanup * Shift lint to react query * Merge master solve import lint issue * Update lints UI * Optional params * Add entity and schema name * Rename lints to linter, format results * Wrap in markdown * Adjust layout of some components * Add CTAs to each lint * fix ignored problems section * Update type * Add cta buttons for auth rls initialization issues * Change metadata object * Remove unneeded file. * Add a component for CTA for a lint row. * Make the lint query to be more type-safe. * Various fixes to the lint table. * Show the remediation link. * Fix the button icons. --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com> Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
10 files changed
+935
-4
No files matched your search
@@ -56,7 +56,7 @@ const FilterPopover = ({
|
||||
defaultChecked: activeOptions.includes(option[valueKey]),
|
||||
}
|
||||
})}
|
||||
></Checkbox.Group>
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-overlay bg-surface-200 py-2 px-3">
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { partition, sortBy } from 'lodash'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
@@ -30,15 +30,18 @@ import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
|
||||
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
|
||||
import ProtectedSchemaWarning from '../ProtectedSchemaWarning'
|
||||
import CreateIndexSidePanel from './CreateIndexSidePanel'
|
||||
import { useParams } from 'common'
|
||||
|
||||
const Indexes = () => {
|
||||
const { project } = useProjectContext()
|
||||
const { schema: urlSchema, table } = useParams()
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedSchema, setSelectedSchema] = useState('public')
|
||||
const [showCreateIndex, setShowCreateIndex] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState<DatabaseIndex>()
|
||||
const [selectedIndexToDelete, setSelectedIndexToDelete] = useState<DatabaseIndex>()
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const {
|
||||
data: allIndexes,
|
||||
refetch: refetchIndexes,
|
||||
@@ -93,6 +96,17 @@ const Indexes = () => {
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (urlSchema !== undefined) {
|
||||
const schema = schemas?.find((s) => s.name === urlSchema)
|
||||
if (schema !== undefined) setSelectedSchema(schema.name)
|
||||
}
|
||||
}, [urlSchema, isSuccessSchemas])
|
||||
|
||||
useEffect(() => {
|
||||
if (table !== undefined) setSearch(table)
|
||||
}, [table])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pb-8">
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { LINT_TYPES, Lint } from 'data/lint/lint-query'
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
|
||||
const assertUnreachable = (n: never) => {
|
||||
console.error('Unhandled lint type', n)
|
||||
}
|
||||
|
||||
export function getHumanReadableTitle(title: LINT_TYPES) {
|
||||
switch (title) {
|
||||
case 'unindexed_foreign_keys':
|
||||
return 'Unindexed foreign keys'
|
||||
case 'auth_users_exposed':
|
||||
return 'Exposed Auth Users'
|
||||
case 'auth_rls_initplan':
|
||||
return 'Auth RLS Initialization Plan'
|
||||
case 'no_primary_key':
|
||||
return 'No Primary Key'
|
||||
case 'unused_index':
|
||||
return 'Unused Index'
|
||||
case 'multiple_permissive_policies':
|
||||
return 'Multiple Permissive Policies'
|
||||
default:
|
||||
assertUnreachable(title)
|
||||
throw new Error('This case should never be reached')
|
||||
}
|
||||
}
|
||||
|
||||
export const LintCTA = ({
|
||||
title,
|
||||
projectRef,
|
||||
metadata,
|
||||
}: {
|
||||
title: LINT_TYPES
|
||||
projectRef: string
|
||||
metadata: Lint['metadata']
|
||||
}) => {
|
||||
switch (title) {
|
||||
case 'unindexed_foreign_keys':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={`/project/${projectRef}/database/indexes?schema=${metadata?.schema}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Create an index
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
|
||||
case 'auth_users_exposed':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
<Link href={`/project/${projectRef}/editor`} target="_blank" rel="noreferrer">
|
||||
View table
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
case 'auth_rls_initplan':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
<Link href={`/project/${projectRef}/auth/policies`} target="_blank" rel="noreferrer">
|
||||
View policies
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
case 'no_primary_key':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
<Link href={`/project/${projectRef}/editor`} target="_blank" rel="noreferrer">
|
||||
View table
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
case 'unused_index':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={`/project/${projectRef}/database/indexes?schema=${metadata?.schema}&table=${metadata?.name}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View index
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
case 'multiple_permissive_policies':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={`/project/${projectRef}/auth/policies?schema=${metadata?.schema}&search=${metadata?.name}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View policies
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
default:
|
||||
assertUnreachable(title)
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Eye, EyeIcon, EyeOff, HelpCircle, Table2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { LINT_TYPES, Lint } from 'data/lint/lint-query'
|
||||
import { useLocalStorageQuery } from 'hooks'
|
||||
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Modal,
|
||||
TooltipContent_Shadcn_,
|
||||
TooltipTrigger_Shadcn_,
|
||||
Tooltip_Shadcn_,
|
||||
} from 'ui'
|
||||
import { Markdown } from '../Markdown'
|
||||
import { LintCTA, getHumanReadableTitle } from './ReportLints.utils'
|
||||
|
||||
type ReportLintsTableRowProps = {
|
||||
lint: Lint
|
||||
}
|
||||
|
||||
const ReportLintsTableRow = ({ lint }: ReportLintsTableRowProps) => {
|
||||
const { ref } = useParams()
|
||||
const [selectedLint, setSelectedLint] = useState<Lint | null>(null)
|
||||
|
||||
const [lintIgnoreList, setLintIgnoreList] = useLocalStorageQuery<string[]>(
|
||||
LOCAL_STORAGE_KEYS.PROJECT_LINT_IGNORE_LIST,
|
||||
[]
|
||||
)
|
||||
const isIgnored = lintIgnoreList.includes(lint.cache_key)
|
||||
|
||||
// if the lint type can't be handled (there's no CTA text defined), don't render it
|
||||
if (!LINT_TYPES.includes(lint.name)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const toggleLintIgnore = () => {
|
||||
let currentIgnoreList = []
|
||||
const cacheKey = lint.cache_key
|
||||
|
||||
// Check if the cacheKey exists in the array and ignore or unignore it
|
||||
const index = lintIgnoreList.indexOf(cacheKey)
|
||||
if (index !== -1) {
|
||||
currentIgnoreList = lintIgnoreList.filter((l) => l !== cacheKey)
|
||||
} else {
|
||||
currentIgnoreList = lintIgnoreList.concat(cacheKey)
|
||||
}
|
||||
setLintIgnoreList(currentIgnoreList)
|
||||
setSelectedLint(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table.tr>
|
||||
<Table.td className="w-20 align-top">
|
||||
<Badge
|
||||
className="!rounded w-16 font-mono text-center justify-center"
|
||||
variant={
|
||||
lint.level === 'ERROR' ? 'destructive' : lint.level === 'WARN' ? 'warning' : 'default'
|
||||
}
|
||||
>
|
||||
{lint.level}
|
||||
</Badge>
|
||||
</Table.td>
|
||||
|
||||
<Table.td className="flex flex-col gap-y-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-foreground">{getHumanReadableTitle(lint.name)}</p>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<HelpCircle size={14} />
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="bottom" className="w-72">
|
||||
{lint.description}
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
</div>
|
||||
|
||||
{(lint.metadata?.type === 'table' || lint.metadata?.type === 'view') && (
|
||||
<div className="flex items-center gap-x-4 text-xs py-1">
|
||||
{lint.metadata?.schema && (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<p className="text-foreground-lighter">schema</p>
|
||||
<p className="text-foreground font-mono">{lint.metadata?.schema}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-x-1">
|
||||
{lint.metadata?.type === 'table' && (
|
||||
<Table2 size={15} strokeWidth={1.5} className="text-foreground-lighter" />
|
||||
)}
|
||||
{lint.metadata?.type === 'view' && (
|
||||
<Eye size={15} strokeWidth={1.5} className="text-foreground-lighter" />
|
||||
)}
|
||||
<p className="text-foreground font-mono">{lint.metadata?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{lint.detail && (
|
||||
<Markdown
|
||||
className="text-foreground-light max-w-full leading-relaxed"
|
||||
content={lint.detail}
|
||||
/>
|
||||
)}
|
||||
{lint.remediation && (
|
||||
<p className="text-foreground-light max-w-full leading-relaxed">
|
||||
You can read more about this lint rule and the best ways to remedy it{' '}
|
||||
<a
|
||||
className="underline text-foreground-light transition-all hover:text-foreground hover:decoration-brand"
|
||||
href={lint.remediation}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<LintCTA title={lint.name} projectRef={ref!} metadata={lint.metadata} />
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="px-1"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setSelectedLint(lint)
|
||||
}}
|
||||
icon={isIgnored ? <EyeIcon strokeWidth={1} /> : <EyeOff strokeWidth={1} />}
|
||||
/>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="bottom">
|
||||
{isIgnored ? 'Unignore problem' : 'Ignore problem'}
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
<Modal
|
||||
size="small"
|
||||
alignFooter="right"
|
||||
visible={selectedLint !== null}
|
||||
onCancel={() => setSelectedLint(null)}
|
||||
onConfirm={() => toggleLintIgnore()}
|
||||
header={<h3>Confirm to {isIgnored ? 'unignore' : 'ignore'} this lint</h3>}
|
||||
>
|
||||
<div className="py-4">
|
||||
<Modal.Content>
|
||||
<p className="text-sm">
|
||||
{isIgnored
|
||||
? 'Unignoring this lint will remove it from the Ignored Issues list. It will move it back to the main list above.'
|
||||
: 'Ignoring this lint will remove it from the main list. It will still be visible in the Ignored Issues list below.'}
|
||||
</p>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReportLintsTableRow
|
||||
@@ -108,6 +108,11 @@ const ReportsMenu = () => {
|
||||
key: 'query-performance',
|
||||
url: `/project/${ref}/reports/query-performance`,
|
||||
},
|
||||
{
|
||||
name: 'Project Linter',
|
||||
key: 'linter',
|
||||
url: `/project/${ref}/reports/linter`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const lintKeys = {
|
||||
lint: (projectRef: string | undefined) => ['projects', projectRef, 'lint'] as const,
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||
import { executeSql } from '../sql/execute-sql-query'
|
||||
import { lintKeys } from './keys'
|
||||
|
||||
export const LINT_SQL = `(
|
||||
with foreign_keys as (
|
||||
select
|
||||
cl.relnamespace::regnamespace as schema_,
|
||||
cl.oid::regclass as table_,
|
||||
ct.conname as fkey_name,
|
||||
ct.conkey col_attnums
|
||||
from
|
||||
pg_constraint ct
|
||||
join pg_class cl -- fkey owning table
|
||||
on ct.conrelid = cl.oid
|
||||
left join pg_depend d
|
||||
on d.objid = cl.oid
|
||||
and d.deptype = 'e'
|
||||
where
|
||||
ct.contype = 'f' -- foreign key constraints
|
||||
and d.objid is null -- exclude tables that are dependencies of extensions
|
||||
and cl.relnamespace::regnamespace::text not in (
|
||||
'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions'
|
||||
)
|
||||
),
|
||||
index_ as (
|
||||
select
|
||||
indrelid::regclass as table_,
|
||||
indexrelid::regclass as index_,
|
||||
string_to_array(indkey::text, ' ')::smallint[] as col_attnums
|
||||
from
|
||||
pg_index
|
||||
where
|
||||
indisvalid
|
||||
)
|
||||
select
|
||||
'unindexed_foreign_keys' as name,
|
||||
'INFO' as level,
|
||||
'EXTERNAL' as facing,
|
||||
'Identifies foreign key constraints without a covering index, which can impact database performance.' as description,
|
||||
format(
|
||||
'Table \`%s.%s\` has a foreign key \`%s\` without a covering index. This can lead to suboptimal query performance.',
|
||||
fk.schema_,
|
||||
fk.table_,
|
||||
fk.table_,
|
||||
fk.fkey_name
|
||||
) as detail,
|
||||
'https://supabase.github.io/splinter/0001_unindexed_foreign_keys' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', fk.schema_,
|
||||
'name', fk.table_,
|
||||
'type', 'table',
|
||||
'fkey_name', fk.fkey_name,
|
||||
'fkey_columns', fk.col_attnums
|
||||
) as metadata,
|
||||
format('0001_unindexed_foreign_keys_%s_%s_%s', fk.schema_, fk.table_, fk.fkey_name) as cache_key
|
||||
from
|
||||
foreign_keys fk
|
||||
left join index_ idx
|
||||
on fk.table_ = idx.table_
|
||||
and fk.col_attnums = idx.col_attnums
|
||||
where
|
||||
idx.index_ is null
|
||||
order by
|
||||
fk.table_,
|
||||
fk.fkey_name)
|
||||
union all
|
||||
(
|
||||
select
|
||||
'auth_users_exposed' as name,
|
||||
'WARN' as level,
|
||||
'EXTERNAL' as facing,
|
||||
'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in the public schema, potentially compromising user data security.' as description,
|
||||
format(
|
||||
'View/Materialized View "%s" in the public schema may expose \`auth.users\` data to anon or authenticated roles.',
|
||||
c.relname
|
||||
) as detail,
|
||||
'https://supabase.github.io/splinter/0002_auth_users_exposed' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', 'public',
|
||||
'name', c.relname,
|
||||
'type', 'view',
|
||||
'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null)
|
||||
) as metadata,
|
||||
format('auth_users_exposed_%s_%s', 'public', c.relname) as cache_key
|
||||
from
|
||||
pg_depend d
|
||||
join pg_rewrite r
|
||||
on r.oid = d.objid
|
||||
join pg_class c
|
||||
on c.oid = r.ev_class
|
||||
join pg_namespace n
|
||||
on n.oid = c.relnamespace
|
||||
where
|
||||
d.refobjid = 'auth.users'::regclass
|
||||
and d.deptype = 'n'
|
||||
and c.relkind in ('v', 'm') -- v for view, m for materialized view
|
||||
and n.nspname = 'public'
|
||||
and (
|
||||
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
|
||||
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
|
||||
)
|
||||
-- Exclude self
|
||||
and c.relname <> '0002_auth_users_exposed'
|
||||
group by
|
||||
c.relname, c.oid)
|
||||
union all
|
||||
(/*
|
||||
Usage of auth.uid(), auth.role() ... are common in RLS policies.
|
||||
|
||||
A naive policy like
|
||||
|
||||
create policy "rls_test_select" on test_table
|
||||
to authenticated
|
||||
using ( auth.uid() = user_id )
|
||||
|
||||
will re-evaluate the auth.uid() function for every row. That can result in 100s of times slower performance
|
||||
https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select
|
||||
|
||||
To resolve that issue, the function calls can be wrapped like "(select auth.uid())" which causes the value to
|
||||
be executed exactly 1 time per query
|
||||
|
||||
For example:
|
||||
|
||||
create policy "rls_test_select" on test_table
|
||||
to authenticated
|
||||
using ( (select auth.uid()) = user_id )
|
||||
|
||||
NOTE:
|
||||
This lint requires search_path = '' or 'auth' not in search_path
|
||||
because qual and with_check are dependent on search_path to determine if function calls include the "auth" schema
|
||||
*/
|
||||
|
||||
|
||||
|
||||
with policies as (
|
||||
select
|
||||
nsp.nspname as schema_,
|
||||
polrelid::regclass table_,
|
||||
pc.relrowsecurity is_rls_active,
|
||||
polname as policy_name,
|
||||
polpermissive as is_permissive, -- if not, then restrictive
|
||||
(select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles,
|
||||
case polcmd
|
||||
when 'r' then 'SELECT'
|
||||
when 'a' then 'INSERT'
|
||||
when 'w' then 'UPDATE'
|
||||
when 'd' then 'DELETE'
|
||||
when '*' then 'ALL'
|
||||
end as command,
|
||||
qual,
|
||||
with_check
|
||||
from
|
||||
pg_policy pa
|
||||
join pg_class pc
|
||||
on pa.polrelid = pc.oid
|
||||
join pg_namespace nsp
|
||||
on pc.relnamespace = nsp.oid
|
||||
join pg_policies pb
|
||||
on pc.relname = pb.tablename
|
||||
and nsp.nspname = pb.schemaname
|
||||
and pa.polname = pb.policyname
|
||||
)
|
||||
select
|
||||
'auth_rls_initplan' as name,
|
||||
'WARN' as level,
|
||||
'EXTERNAL' as facing,
|
||||
'Detects if calls to \`auth.<function>()\` in RLS policies are being unnecessarily re-evaluated for each row' as description,
|
||||
format(
|
||||
'Table \`%s\` has a row level security policy \`%s\` that re-evaluates an auth.<function>() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \`auth.<function>()\` with \`(select auth.<function>())\`. See https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select for more.',
|
||||
table_,
|
||||
policy_name
|
||||
) as detail,
|
||||
'https://supabase.github.io/splinter/0003_auth_rls_initplan' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', schema_,
|
||||
'name', table_,
|
||||
'type', 'table'
|
||||
) as metadata,
|
||||
format('auth_rls_init_plan_%s_%s_%s', schema_, table_, policy_name) as cache_key
|
||||
from
|
||||
policies
|
||||
where
|
||||
is_rls_active
|
||||
and (
|
||||
(
|
||||
-- Example: auth.uid()
|
||||
qual ~ '(auth)\.(uid|jwt|role|email)\(\)'
|
||||
-- Example: select auth.uid()
|
||||
and lower(qual) !~ 'select\s+(auth)\.(uid|jwt|role|email)\(\)'
|
||||
)
|
||||
or
|
||||
(
|
||||
-- Example: auth.uid()
|
||||
with_check ~ '(auth)\.(uid|jwt|role|email)\(\)'
|
||||
-- Example: select auth.uid()
|
||||
and lower(with_check) !~ 'select\s+(auth)\.(uid|jwt|role|email)\(\)'
|
||||
)
|
||||
))
|
||||
union all
|
||||
(
|
||||
select
|
||||
'no_primary_key' as name,
|
||||
'INFO' as level,
|
||||
'EXTERNAL' as facing,
|
||||
'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as description,
|
||||
format(
|
||||
'Table \`%s.%s\` does not have a primary key',
|
||||
pgns.nspname,
|
||||
pgc.relname
|
||||
) as detail,
|
||||
'https://supabase.github.io/splinter/0004_no_primary_key' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', pgns.nspname,
|
||||
'name', pgc.relname,
|
||||
'type', 'table'
|
||||
) as metadata,
|
||||
format(
|
||||
'no_primary_key_%s_%s',
|
||||
pgns.nspname,
|
||||
pgc.relname
|
||||
) as cache_key
|
||||
from
|
||||
pg_class pgc
|
||||
join pg_namespace pgns
|
||||
on pgns.oid = pgc.relnamespace
|
||||
left join pg_index pgi
|
||||
on pgi.indrelid = pgc.oid
|
||||
where
|
||||
pgc.relkind = 'r' -- regular tables
|
||||
and pgns.nspname not in (
|
||||
'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'pgsodium'
|
||||
)
|
||||
group by
|
||||
pgc.oid,
|
||||
pgns.nspname,
|
||||
pgc.relname
|
||||
having
|
||||
max(coalesce(pgi.indisprimary, false)::int) = 0)
|
||||
union all
|
||||
(
|
||||
select
|
||||
'unused_index' as name,
|
||||
'INFO' as level,
|
||||
'EXTERNAL' as facing,
|
||||
'Detects if an index has never been used and may be a candidate for removal.' as description,
|
||||
format(
|
||||
'Index \`%s\` on table \`%s.%s\` has not been used',
|
||||
psui.indexrelname,
|
||||
psui.schemaname,
|
||||
psui.relname
|
||||
) as detail,
|
||||
'https://supabase.github.io/splinter/0005_unused_index' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', psui.schemaname,
|
||||
'name', psui.relname,
|
||||
'type', 'table'
|
||||
) as metadata,
|
||||
format(
|
||||
'unused_index_%s_%s_%s',
|
||||
psui.schemaname,
|
||||
psui.relname,
|
||||
psui.indexrelname
|
||||
) as cache_key
|
||||
|
||||
from
|
||||
pg_catalog.pg_stat_user_indexes psui
|
||||
join pg_catalog.pg_index pi
|
||||
on psui.indexrelid = pi.indexrelid
|
||||
where
|
||||
psui.idx_scan = 0
|
||||
and not pi.indisunique
|
||||
and not pi.indisprimary
|
||||
and psui.schemaname not in (
|
||||
'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'pgsodium'
|
||||
))
|
||||
union all
|
||||
(
|
||||
select
|
||||
'multiple_permissive_policies' as name,
|
||||
'WARN' as level,
|
||||
'EXTERNAL' as facing,
|
||||
'Detects if multiple permissive row level security policies are present on a table for the same \`role\` and \`action\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as description,
|
||||
format(
|
||||
'Table \`%s.%s\` has multiple permissive policies for role \`%s\` for action \`%s\`. Policies include \`%s\`',
|
||||
n.nspname,
|
||||
c.relname,
|
||||
r.rolname,
|
||||
act.cmd,
|
||||
array_agg(p.polname order by p.polname)
|
||||
) as detail,
|
||||
'https://supabase.github.io/splinter/0006_multiple_permissive_policies' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', n.nspname,
|
||||
'name', c.relname,
|
||||
'type', 'table'
|
||||
) as metadata,
|
||||
format(
|
||||
'multiple_permissive_policies_%s_%s_%s_%s',
|
||||
n.nspname,
|
||||
c.relname,
|
||||
r.rolname,
|
||||
act.cmd
|
||||
) as cache_key
|
||||
from
|
||||
pg_catalog.pg_policy p
|
||||
join pg_catalog.pg_class c on p.polrelid = c.oid
|
||||
join pg_catalog.pg_namespace n on c.relnamespace = n.oid
|
||||
join pg_catalog.pg_roles r
|
||||
on p.polroles @> array[r.oid]
|
||||
or p.polroles = array[0::oid],
|
||||
lateral (
|
||||
select x.cmd
|
||||
from unnest((
|
||||
select
|
||||
case p.polcmd
|
||||
when 'r' then array['SELECT']
|
||||
when 'a' then array['INSERT']
|
||||
when 'w' then array['UPDATE']
|
||||
when 'd' then array['DELETE']
|
||||
when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE']
|
||||
else array['ERROR']
|
||||
end as actions
|
||||
)) x(cmd)
|
||||
) act(cmd)
|
||||
where
|
||||
c.relkind = 'r' -- regular tables
|
||||
and n.nspname not in (
|
||||
'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'pgsodium'
|
||||
)
|
||||
and r.rolname not like 'pg_%'
|
||||
and r.rolname not like 'supabase%admin'
|
||||
and not r.rolbypassrls
|
||||
group by
|
||||
n.nspname,
|
||||
c.relname,
|
||||
r.rolname,
|
||||
act.cmd
|
||||
having
|
||||
count(1) > 1)`.trim()
|
||||
|
||||
// Array of all lint rules we handle right now.
|
||||
export const LINT_TYPES = [
|
||||
'unindexed_foreign_keys',
|
||||
'auth_users_exposed',
|
||||
'no_primary_key',
|
||||
'unused_index',
|
||||
'multiple_permissive_policies',
|
||||
'auth_rls_initplan',
|
||||
] as const
|
||||
export type LINT_TYPES = (typeof LINT_TYPES)[number]
|
||||
|
||||
export type Lint = {
|
||||
name: LINT_TYPES
|
||||
level: 'ERROR' | 'WARN' | 'INFO'
|
||||
facing: string
|
||||
description: string
|
||||
detail: string
|
||||
remediation: any
|
||||
metadata: {
|
||||
schema?: string
|
||||
name?: string
|
||||
type?: 'table' | 'view'
|
||||
fkey_name?: string
|
||||
fkey_columns?: number[]
|
||||
} | null
|
||||
cache_key: string
|
||||
}
|
||||
|
||||
export type ProjectLintsVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
}
|
||||
|
||||
const getProjectLints = async (
|
||||
{ projectRef, connectionString }: ProjectLintsVariables,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: LINT_SQL,
|
||||
queryKey: lintKeys.lint(projectRef),
|
||||
},
|
||||
signal
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
export type ProjectLintsData = Lint[]
|
||||
export type ProjectLintsError = unknown
|
||||
|
||||
export const useProjectLintsQuery = <TData = ProjectLintsData>(
|
||||
{ projectRef, connectionString }: ProjectLintsVariables,
|
||||
{ enabled, ...options }: UseQueryOptions<ProjectLintsData, ProjectLintsError, TData> = {}
|
||||
) =>
|
||||
useQuery<ProjectLintsData, ProjectLintsError, TData>(
|
||||
lintKeys.lint(projectRef),
|
||||
({ signal }) => getProjectLints({ projectRef, connectionString }, signal),
|
||||
{
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
...options,
|
||||
}
|
||||
)
|
||||
@@ -48,6 +48,8 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
PGBOUNCER_IPV6_DEPRECATION_WARNING: 'pgbouncer-ipv6-deprecation-warning-dismissed',
|
||||
VERCEL_IPV6_DEPRECATION_WARNING: 'vercel-ipv6-deprecation-warning-dismissed',
|
||||
PGBOUNCER_DEPRECATION_WARNING: 'pgbouncer-deprecation-warning-dismissed',
|
||||
|
||||
PROJECT_LINT_IGNORE_LIST: 'supabase-project-lint-ignore-list',
|
||||
}
|
||||
|
||||
export const OPT_IN_TAGS = {
|
||||
|
||||
@@ -73,7 +73,6 @@ const AuthPoliciesPage: NextPageWithLayout = () => {
|
||||
|
||||
const [showPolicyAiEditor, setShowPolicyAiEditor] = useState(false)
|
||||
const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState<PostgresPolicy>()
|
||||
const isAiAssistantEnabled = useIsRLSAIAssistantEnabled()
|
||||
|
||||
useEffect(() => {
|
||||
if (search) setSearchString(search)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { partition, sortBy } from 'lodash'
|
||||
import { Check, ExternalLink, Loader } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { AccordionTrigger } from '@ui/components/shadcn/ui/accordion'
|
||||
import { FilterPopover } from 'components/interfaces/AuditLogs'
|
||||
import ReportLintsTableRow from 'components/interfaces/Reports/ReportLintsTableRow'
|
||||
import { ReportsLayout } from 'components/layouts'
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { useProjectLintsQuery } from 'data/lint/lint-query'
|
||||
import { useLocalStorageQuery, useSelectedProject } from 'hooks'
|
||||
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import {
|
||||
AccordionContent_Shadcn_,
|
||||
AccordionItem_Shadcn_,
|
||||
Accordion_Shadcn_,
|
||||
Button,
|
||||
LoadingLine,
|
||||
} from 'ui'
|
||||
|
||||
const ProjectLints: NextPageWithLayout = () => {
|
||||
const project = useSelectedProject()
|
||||
const [filters, setFilters] = useState({
|
||||
levels: [] as string[],
|
||||
types: [] as string[],
|
||||
})
|
||||
const [lintIgnoreList] = useLocalStorageQuery<string[]>(
|
||||
LOCAL_STORAGE_KEYS.PROJECT_LINT_IGNORE_LIST,
|
||||
[]
|
||||
)
|
||||
|
||||
const { data, isLoading, isRefetching, refetch } = useProjectLintsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
// sort the lints by level, ERROR should be at the top.
|
||||
const lints = sortBy(data || [], (lint) => {
|
||||
if (lint.level === 'ERROR') return 0
|
||||
if (lint.level === 'WARN') return 1
|
||||
if (lint.level === 'INFO') return 2
|
||||
return 3
|
||||
})
|
||||
|
||||
const [ignoredLints, activeLints] = partition(lints, (lint) =>
|
||||
lintIgnoreList.includes(lint.cache_key)
|
||||
)
|
||||
const filteredLints = useMemo(() => {
|
||||
return activeLints
|
||||
.filter((x) => (filters.levels.length > 0 ? filters.levels.includes(x.level) : x))
|
||||
.filter((x) => (filters.types.length > 0 ? filters.types.includes(x.name) : x))
|
||||
}, [activeLints, filters.levels, filters.types])
|
||||
|
||||
const warnLintsCount = activeLints.filter((x) => x.level === 'WARN').length
|
||||
const errorLintsCount = activeLints.filter((x) => x.level === 'ERROR').length
|
||||
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection>
|
||||
<div className="col-span-12 flex items-center justify-between">
|
||||
<div className="flec flex-col gap-y-1">
|
||||
<h3 className="text-xl text-foreground">Project Linter</h3>
|
||||
<div className="text-sm text-foreground-lighter">
|
||||
Identify common schema problems in your project's database
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-xs prose">Filter by</p>
|
||||
<FilterPopover
|
||||
name="Level"
|
||||
options={[
|
||||
{ name: 'Info', value: 'INFO' },
|
||||
{ name: 'Warning', value: 'WARN' },
|
||||
{ name: 'Error', value: 'ERROR' },
|
||||
]}
|
||||
labelKey="name"
|
||||
valueKey="value"
|
||||
activeOptions={filters.levels}
|
||||
onSaveFilters={(values) => setFilters({ ...filters, levels: values })}
|
||||
/>
|
||||
<FilterPopover
|
||||
name="Type"
|
||||
options={[
|
||||
{ name: 'Unindexed foreign keys', value: 'unindexed_foreign_keys' },
|
||||
{ name: 'Auth users exposed', value: 'auth_users_exposed' },
|
||||
{ name: 'No primary key', value: 'no_primary_key' },
|
||||
{ name: 'Unused index', value: 'unused_index' },
|
||||
{ name: 'Multiple permissive policies', value: 'multiple_permissive_policies' },
|
||||
]}
|
||||
labelKey="name"
|
||||
valueKey="value"
|
||||
activeOptions={filters.types}
|
||||
onSaveFilters={(values) => setFilters({ ...filters, types: values })}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-foreground-light text-xs">
|
||||
Identified {activeLints.length} problems{' '}
|
||||
{warnLintsCount > 0 || errorLintsCount > 0
|
||||
? `(${errorLintsCount > 0 ? `${errorLintsCount} errors` : ''}${errorLintsCount > 0 && warnLintsCount > 0 ? ', ' : ''}${warnLintsCount > 0 ? `${warnLintsCount} warnings` : ''})`
|
||||
: null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<a href="/" target="_blank" rel="noreferrer">
|
||||
Documentation
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={isLoading || isRefetching}
|
||||
loading={isLoading || isRefetching}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Rerun linter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12">
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="level" className="py-2">
|
||||
Level
|
||||
</Table.th>,
|
||||
<Table.th key="header-type" className="py-2">
|
||||
Problem
|
||||
</Table.th>,
|
||||
<Table.th key="header-expand" className="py-2 text-right"></Table.th>,
|
||||
]}
|
||||
body={[
|
||||
<Table.tr key="loader">
|
||||
<Table.td colSpan={12} className="!p-0">
|
||||
<LoadingLine loading={isLoading || isRefetching} />
|
||||
</Table.td>
|
||||
</Table.tr>,
|
||||
...(activeLints.length === 0
|
||||
? [
|
||||
<Table.tr key="empty-state">
|
||||
<Table.td colSpan={6} className="p-3 py-12">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Loader className="animate-spin" size={12} />
|
||||
<p className="text-foreground-light">Checking project for issues...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Check size={12} />
|
||||
<p className="text-foreground-light">
|
||||
No issues have been found for this project
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Table.td>
|
||||
</Table.tr>,
|
||||
]
|
||||
: (filters.levels.length > 0 || filters.types.length > 0) &&
|
||||
filteredLints.length === 0
|
||||
? [
|
||||
<Table.tr key="empty-state">
|
||||
<Table.td colSpan={6} className="p-3 py-12">
|
||||
<p className="text-foreground-light">
|
||||
No problems found based on the selected filters
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>,
|
||||
]
|
||||
: filteredLints.map((lint) => {
|
||||
return <ReportLintsTableRow key={lint.cache_key} lint={lint} />
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ignoredLints.length > 0 && (
|
||||
<div className="col-span-12 flex flex-col text-sm max-w-none gap-8 py-4">
|
||||
<Accordion_Shadcn_ type="single" collapsible>
|
||||
<AccordionItem_Shadcn_ value="1" className="border-none">
|
||||
<AccordionTrigger className="px-4 bg-surface-100 rounded border [&[data-state=open]]:rounded-b-none hover:no-underline">
|
||||
<div className="text-sm text-foreground-light font-normal">
|
||||
Ignored problems ({ignoredLints.length})
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent_Shadcn_>
|
||||
<Table
|
||||
body={
|
||||
(lints ?? []).length === 0 ? (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={6} className="p-3 py-12 text-center">
|
||||
<p className="text-foreground-light">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader className="animate-spin" size={12} />
|
||||
Checking for project issues
|
||||
</>
|
||||
) : (
|
||||
'No issues have been found for this project'
|
||||
)}
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
) : (
|
||||
<>
|
||||
{ignoredLints.map((lint) => {
|
||||
return <ReportLintsTableRow key={lint.cache_key} lint={lint} />
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</AccordionContent_Shadcn_>
|
||||
</AccordionItem_Shadcn_>
|
||||
</Accordion_Shadcn_>
|
||||
</div>
|
||||
)}
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
ProjectLints.getLayout = (page) => <ReportsLayout title="Linter">{page}</ReportsLayout>
|
||||
|
||||
export default ProjectLints
|
||||
Reference in new issue
Block a user