Tamagui Supabase 迁移规范:强制 RLS 行级安全的实战指南与事故复盘
【免费下载链接】tamaguiStyle React fast with 100% parity on React Native, an optional UI kit, and optimizing compiler.项目地址: https://gitcode.com/GitHub_Trending/ta/tamagui
本文以 code/tamagui.dev/supabase/README.md 为骨架,结合 tamagui.dev 站点的真实 Supabase 迁移脚本(code/tamagui.dev/supabase/migrations)与安全事故修复记录展开。你将掌握:为什么 Supabase 中"服务角色独享表"也必须开启 RLS、如何为每种访问模式编写最小化 RLS 策略、如何用 SQL 审计"零 RLS 漏洞",以及如何避免 RLS 策略递归死循环等真实踩坑经验。
核心原则:publicschema 下每张表都必须启用 RLS
Supabase 基于 PostgREST 将publicschema 下的每一张表都暴露为 REST API 端点,并通过默认授权(default grants)将读写权限授予anon与authenticated两个角色。这意味着:
anon角色对应随客户端 bundle 一起分发的公开 anon key,任何人拿到这个 key 就能直接通过 REST 调用你的 API;- 禁用 RLS 并不会隐藏表——它恰恰让表对持有 anon key 的任何人完全可读、可写,与你的应用代码内部如何读取毫无关系。
文档(README.md)原文强调:"This table is only accessed via the service role, so it doesn't need RLS" 是错误认知,并记录了一起真实发生的提权事故——匿名用户通过直接 REST 调用把自己插入到pro_whitelist表,从而自授予 Pro 权限。
这一事故在迁移脚本中有完整佐证:20260630000001_secure_users_and_theme_histories.sql 的头部注释写道:
root cause: several tables were created without enabling row level security (or with an over-broad policy), on the mistaken assumption that "only the app accesses them via the service role" is sufficient. it is not...
为什么服务角色不能替代 RLS
服务角色(service role,代码中对应supabaseAdmin客户端)的权限是绕过 RLS的。它保护的是"应用自己的代码路径",却完全管不到直接打向 PostgREST 的 REST 调用。而 RLS 的作用面恰好是"每个数据库行"这一层,无论请求是来自 PostgREST 还是其他 SQL 客户端,只要角色不是绕过 RLS 的服务角色,就会被策略约束。
因此正确的思考方式是:
- RLS 是唯一能约束"持有 anon key 的任何人直连数据库"的防线;
- 服务角色绕过 RLS,恰恰说明"服务角色独享表"与"开启 RLS"二者不冲突——开启 RLS 但不添加任何策略,服务角色照常工作,而 anon / authenticated 得到默认拒绝。
每张新表的四条强制规则
按 README.md 的要求,任何新建表都必须遵守:
- 无条件开启 RLS:
alter table public.<t> enable row level security;——没有例外; - 仅服务角色访问(如
supabaseAdmin):开启 RLS,不添加任何策略。服务角色绕过 RLS 照常读写,anon / authenticated 默认被拒绝; - 需要用户级访问:添加最窄的策略,用
auth.uid() = <owner col>做行归属判断,并且把cmd精确限定到具体操作(for select、for update等),不要图省事写for all; - 刻意公开读(如共享目录 / 公共数据):只写
for select to public using (true),永远不要写覆盖写操作的公共策略。
禁止事项
- 永远不要写
to public with check (true)的 INSERT 策略——这等于允许 anon 匿名写入; - 如果只有服务端插入数据,就依赖服务角色,不添加任何 INSERT 策略。
迁移后必须执行的审计 SQL
在添加迁移后,文档要求验证线上真实状态与预期一致。下面这条查询会找出publicschema 下所有未开启 RLS 的表,应返回零行:
select c.relname, c.relrowsecurity as rls from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind = 'r' and c.relrowsecurity = false; -- ^ should return zero rows这段 SQL 是 RLS 合规的"体检"命令:pg_class.relrowsecurity为false即表示该表未启用 RLS,任何非零结果都是必须立即修复的合规缺口。建议将其作为每次部署前的固定检查项(可放到 CI 或发布脚本中)。
仓库实战:从"无 RLS"到"策略化"的迁移样本
反面教材:当初为什么出事
20260202000001_create_pro_whitelist.sql 是事故表的原始创建脚本,注释里明确写着:
-- NO RLS - this table is only accessed via supabaseAdmin (service role) -- this prevents any client-side manipulation正是这个"仅服务角色访问、不需要 RLS"的假设,最终酿成了 anon 自助写入pro_whitelist的提权事故。这张表用于"授予特定 GitHub 用户 Pro 权限"(github_username唯一索引 +created_by记录管理员),一旦被匿名写入,攻击者即可给自己白名单化,属于典型的高敏感表,却长期裸奔。
同样裸奔的还有public.theme_histories(anon 拥有全量 CRUD)和public.team_subscriptions/public.team_members(匿名可读写任意团队的席位与成员)。
修复迁移的正确姿势
事故修复迁移 20260630000001_secure_users_and_theme_histories.sql 完整示范了"开启 RLS + 零策略/最小策略"的组合拳:
-- 1) 用户表:删除"允许任意 authenticated 用户搜索他人"的过宽策略 -- (该策略让每个人都能读到 email、billing_address、payment_method 等字段) drop policy if exists "Allow users to search other users" on public.users; -- 2) theme_histories:启用 RLS,只保留公开读(主题通过 URL 共享) alter table public.theme_histories enable row level security; drop policy if exists "Public can read shared themes" on public.theme_histories; create policy "Public can read shared themes" on public.theme_histories for select to public using (true); -- 3) pro_whitelist:启用 RLS,不添加任何策略(服务角色专属) alter table public.pro_whitelist enable row level security; -- 4) team_subscriptions + team_members:启用 RLS,不添加任何策略 alter table public.team_subscriptions enable row level security; alter table public.team_members enable row level security; -- 5) 删除无角色限制、with check (true) 的冗余写策略 drop policy if exists "Service role can insert domain history" on public.project_domain_history;注意第 5 条:原本名为"Service role can insert domain history"的策略没有角色限制(默认to public)且with check (true),导致 anon/authenticated 也能插入任意行。由于真实插入路径走服务角色(绕过 RLS),这个策略完全冗余,直接删除即可。
从"公开读"收紧到"服务角色独享"的第二阶段
20260630000002_theme_histories_private_read.sql 展示了进一步收紧:上一轮为兼容分享功能保留了public读策略,但这仍让持有 anon key 的任何人能枚举所有用户的search_query_prompts + theme_data + user_id。因此第二阶段把读路径也全部迁到服务端 API 路由(supabaseAdmin),然后直接删除读策略,使 RLS 处于"已启用但零策略"的完全锁定状态。
该文件还特别提示了一个部署顺序问题:
NOTE: deploy ordering matters - the getTheme() change to read via the service role must be live BEFORE this policy is dropped, otherwise public /theme/ share links would break.
即:必须先上线"读走服务角色"的代码,再删策略,否则公开分享链接会先坏掉。这是 RLS 收紧迁移的标准节奏——先改代码路径,后收策略。
存储桶与函数级加固
20260630000003_storage_upload_lockdown_and_search_path.sql 展示了 RLS 之外的配套加固:
-- 存储桶:删除公开 INSERT 策略,并限定 mime 类型与大小 drop policy if exists "Theme Image Upload" on storage.objects; update storage.buckets set allowed_mime_types = array['image/png'], file_size_limit = 2097152 -- 2 MB where id = 'theme-og-images';事故背景:theme-og-images桶的 "Theme Image Upload" 策略是to public with check (bucket_id = 'theme-og-images'),无认证、无 mime/大小限制,任何持有 anon key 的人都可向 tamagui 的 CDN 桶上传任意文件。修复后仅保留公开读(OG 卡片仍需加载),写入全部走服务角色。
同一文件的另一个重点是给SECURITY DEFINER触发器函数固定search_path:
alter function public.handle_new_user() set search_path = '';这是防止"搜索路径劫持"的标准做法:若不固定,攻击者可以在路径前置位置植入同名对象来劫持函数内部的未限定引用。
高级专题:RLS 策略递归与 SECURITY DEFINER 破环
20260630000004_fix_projects_rls_recursion.sql 记录了一个极易踩中的深坑——RLS 策略互相递归导致死循环:
public.projects的 "Team members can view..." SELECT 策略内部执行EXISTS(... from project_team_members ...);public.project_team_members的 "Project owners can manage..." ALL 策略内部执行EXISTS(... from projects ...);
于是评估任一张表的策略都会触发另一张表的策略,无限循环,表现为authenticated角色直接 SELECTpublic.projects时报错"infinite recursion detected in policy for relation projects"。
解决方案是把跨表检查抽成SECURITY DEFINER辅助函数——函数以属主身份执行、绕过被查询表的 RLS,从而切断循环;同时用set search_path = ''固定搜索路径防止被劫持:
create or replace function public.is_project_owner(p_project_id uuid, p_user_id uuid) returns boolean language sql security definer set search_path = '' stable as $$ select exists ( select 1 from public.projects where id = p_project_id and user_id = p_user_id ); $$;策略改为调用函数:
create policy "Team members can view projects they belong to" on public.projects for select to public using (public.is_project_member(id, (select auth.uid())));这里还有一层重要的"失败关闭(fails closed)"观察:该站点应用通过服务角色读取这两张表(绕过 RLS),因此递归问题在线上是"潜伏"的,直到有人用 authenticated 角色直连才暴露。RLS 配置错误不一定会立刻炸在你脸上,这正是必须用审计 SQL 主动验证的原因。
对照样本:用户级策略与索引设计
不是所有表都该"零策略"。需要用户自管理的表应使用最窄的归属策略,仓库中有两个早期迁移可作对照:
20240629220913_add_rls_to_users_private.sql 为users_private表只开了"用户更新自己的行":
create policy "Allow users to update their own row" on "public"."users_private" as permissive for update to public using (auth.uid() = id); alter table "public"."users_private" enable row level security;注意顺序:先建策略、后开启 RLS,避免开启瞬间出现策略空缺窗口。
20230731081829_add_rls_to_app_installations.sql 则示范了基于关联表存在性的读策略:
create policy "Enable users to see their own app installations" on "public"."app_installations" as permissive for select to public using ((EXISTS ( SELECT 1 FROM subscription_items WHERE (subscription_items.id = app_installations.subscription_item_id))));同样地,pro_whitelist表还配套了索引以支撑访问检查:20260202000001_create_pro_whitelist.sql 中pro_whitelist_github_username_idx用于按 GitHub 用户名快速查找。
服务角色的代码侧印证
"所有合法应用访问都走服务角色"并非口头承诺,在站点代码中可大量验证:supabaseAdmin(服务角色客户端)被广泛用于订阅、Bento 下载、Discord、GitHub bot 等 API 路由,例如 add-team-seats+api.ts、admin/whitelist+api.ts、admin/impersonate+api.ts 等。这正是文档所述安全模型的落点:应用逻辑全部服务端化,客户端只接触受限的 RLS 面。
另外,code/tamagui.dev/supabase/config.toml 中[api]段的max_rows = 1000也值得注意——这是对"意外或恶意请求"的又一层纵深防御,防止单次 REST 调用拉走全表数据。
可落地的检查清单
综合文档与仓库实践,为你的下一个 Supabase 迁移整理如下清单:
- 新表第一行就写
alter table public.<t> enable row level security;,没有例外; - 按访问模型选择策略形态:服务角色专属 → 零策略;用户自管 →
auth.uid() = <owner>且限定具体for <cmd>;公开读 → 仅for select to public using (true); - 迁移后运行文档的审计 SQL,确认
public下无任何relrowsecurity = false的表; - 收紧既有策略时,遵循"先改代码路径(服务角色),后删策略"的部署顺序,避免线上功能中断;
- 跨表引用的 RLS 策略,优先抽成
SECURITY DEFINER函数并set search_path = '',同时留意递归死循环的报错特征; - 存储桶策略与
SECURITY DEFINER函数的search_path一并纳入安全检查范围,不给"影子对象劫持"留机会; - 把审计 SQL 接入 CI/发布流程,让"RLS 缺口"在合并前而非事故后被发现。
记住那句被事故验证过的判断:"仅服务角色访问,所以不需要 RLS" 是 Supabase 安全模型中最危险的假设。RLS 是唯一能把 anon key 挡在行数据之外的手段,而它只需要一行alter table。
【免费下载链接】tamaguiStyle React fast with 100% parity on React Native, an optional UI kit, and optimizing compiler.项目地址: https://gitcode.com/GitHub_Trending/ta/tamagui
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考