Admin console không tốn đồng nào để host: Vite, Supabase RLS, antd
Chuyển admin đọc góp ý từ Next.js sang SPA Vite tĩnh: anon key cộng RLS là cổng duy nhất, bẫy email link và PKCE, và một CSP vừa với antd.

Trong bài này
Khi bảy app đã gửi được góp ý vào chung một bảng Supabase, như mình kể ở bài Một hộp thư góp ý cho bảy app iOS, mình cần một chỗ để đọc. Tin báo Telegram đủ cho câu "có cái mới tới". Nó không đủ cho câu "cho mình xem mọi bug Baton còn mở trong hai tuần qua và đánh dấu xong ba cái".
Nên mình dựng một admin console, rồi dựng lại ngay trong ngày. Bản đầu là app Next.js có proxy, server action và nonce CSP theo từng request. Bản đang chạy bây giờ, trên một subdomain của domain cá nhân, là một single-page app Vite + React phục vụ dưới dạng file tĩnh trên Vercel: không server, không serverless function, không có gì tính tiền theo request. Lần viết lại đó đổi 70 file, thêm 3.122 dòng và xoá 6.116 dòng.
Thứ làm được điều đó là một quyết định: để Postgres là nơi duy nhất quyết định ai được xem gì. Bài này nói về quyết định đó, phần row level security hiện thực nó, hai cái bẫy email link mà mỗi cái làm mình mất một lần đăng nhập, và chỗ thoả hiệp CSP mà Ant Design ép một host tĩnh phải chấp nhận.
Vì sao bỏ được server
Bản Next.js chạy ổn. File proxy.ts chạy ở mỗi request và làm ba việc: tạo nonce CSP, refresh cookie session của Supabase, và redirect người chưa đăng nhập về /login. Server action đổi trạng thái hoặc xoá một góp ý, sau một bước kiểm tra admin.
Khi mình liệt kê xem mỗi phần đang bảo vệ cái gì, danh sách trống trơn. Quyền truy cập dữ liệu vốn đã do Postgres canh. Server action kiểm tra "user này có phải admin không" rồi gọi đúng lệnh Supabase mà trình duyệt cũng gọi được, cùng anon key, cùng session của user. Rồi RLS lại kiểm tra đúng điều đó lần nữa. Redirect về /login là tiện ích UX, không phải ranh giới bảo mật. Thứ duy nhất host tĩnh thật sự không làm được là nonce theo request, và hoá ra nó ít quan trọng hơn mình tưởng (xem phần CSP).
Đây cũng là bài học trong bài bỏ BFF ở admin Saramin: một lớp server kiểm tra lại những gì backend đã ép buộc thì chỉ là nghi thức. Ở đây còn rẻ hơn nữa, vì "backend" chính là Postgres.

SPA dùng Vite 8, React 19, react-router, TanStack Query để đọc, và gọi supabase-js trực tiếp để ghi. vercel.json đặt framework là vite, output là dist, và một rewrite đưa mọi path không phải asset về index.html.
Anon key là public, nên RLS phải kín
Supabase client chỉ chạy trên trình duyệt nghĩa là anon key nằm trong bundle JavaScript. Chuyện đó ổn, key sinh ra để làm vậy, nhưng chỉ khi luật trong database kín. Ai cũng có thể lấy key đó gọi thẳng REST API, bỏ qua hoàn toàn UI của mình. Nên câu hỏi cho mỗi bảng không phải "UI làm gì" mà là "một user bất kỳ, đã đăng nhập hay ẩn danh, làm được gì với key này".
Migration mở hộp thư cho admin trả lời câu đó trong bốn phần.
1. Một allow-list không ai đọc được. admin_users giữ email của admin. Bật RLS, không có policy, mọi quyền đều bị thu hồi khỏi anon và authenticated. Chỉ sửa được bằng SQL.
2. Một function duy nhất ra quyết định.
create or replace function public.is_feedback_admin()
returns boolean
language sql stable
security definer
set search_path = ''
as $$
select coalesce((
select true
from auth.users u
join public.admin_users a on a.email = lower(u.email)
where u.id = auth.uid()
and u.email_confirmed_at is not null
and a.email = lower(auth.jwt() ->> 'email')
limit 1
), false);
$$;
revoke all on function public.is_feedback_admin() from public, anon;
grant execute on function public.is_feedback_admin() to authenticated;security definer cho nó đọc admin_users và auth.users, những thứ người gọi không đọc được. search_path = '' ngăn ai đó che tên bảng bằng schema của riêng họ. Nó kiểm cả email trong JWT lẫn dòng thật trong auth.users, và đòi email đã xác nhận, nên một lượt đăng ký chưa xác nhận dùng địa chỉ của mình sẽ chẳng được gì.
3. Update giới hạn theo cột. RLS quyết định dòng nào. Quyền theo cột quyết định trường nào. Admin được đọc và xoá góp ý, nhưng chỉ được update hai cột phân loại:
revoke all on table public.app_feedback from anon, authenticated;
grant select, delete on table public.app_feedback to authenticated;
grant update (status, admin_note) on table public.app_feedback to authenticated;
create policy feedback_admin_update on public.app_feedback
for update to authenticated
using ((select public.is_feedback_admin()))
with check ((select public.is_feedback_admin()));Nên kể cả session admin bị lộ cũng không viết lại được lời người dùng đã nói, và không role client nào insert được. Dòng mới vẫn chỉ đến từ edge function, qua phần validate của nó.
4. Storage và Realtime theo cùng một luật. Policy trên storage.objects cho admin ký URL và xoá file trong bucket screenshot. app_feedback được thêm vào publication của Realtime, và Realtime áp đúng policy select đó, nên chỉ admin nhận được sự kiện thay đổi.

Mình không tin chuyện này chỉ vì đọc thấy đúng. Một script test live gọi project thật dưới ba danh tính: anon, một user tạm không phải admin, và admin. Nó qua 28 trên 28 lần kiểm. Anon nhận 401 ở mọi thứ. User không phải admin thấy và đổi được 0 dòng, không ký được URL screenshot. Admin đọc được, update được status và note, ký và xoá được screenshot, nhưng không đổi được message, không insert được dòng nào, không đọc được admin_users. Cùng lượt chạy đó xác nhận edge function vẫn validate như cũ.
UI gọi rpc('is_feedback_admin') vì đúng một lý do: để hiện "tài khoản này không có quyền" thay vì một hộp thư trống. Giấu bớt trên UI là phép lịch sự. Các luật ở trên mới là cổng.
Email link: hai cái bẫy
Tài khoản chủ đăng nhập bằng email, với shouldCreateUser: false để form không tạo được tài khoản mới. Có hai chỗ hỏng với cái link trước khi nó chạy được.
Bẫy 1: fallback về Site URL. Site URL của project Supabase này thuộc về Linkeeper, và phải như vậy, vì đăng nhập Apple và Google của Linkeeper dùng nó. Nếu admin xin link với redirectTo trỏ về callback của chính nó, mà URL đó không có trong allow-list Redirect URLs của project, Supabase không báo lỗi. Nó lặng lẽ gửi người dùng về Site URL. Link mở ra site của Linkeeper, token dùng một lần đã bị tiêu, và admin vẫn chưa đăng nhập. Cách sửa là thêm origin của admin (và port dev ở local) vào allow-list, không đụng tới Site URL.
Bẫy 2: PKCE và ?code=. Client trên trình duyệt dùng flow PKCE, nên khi redirect đã được phép, link đáp xuống /auth/callback?code=…. Trang callback đầu tiên của mình chỉ hiểu fragment #access_token kiểu cũ, và báo "link không hợp lệ". Code phải được đổi lấy session, và code verifier cho lần đổi đó nằm trong localStorage của trình duyệt đã xin email. Mở link ở trình duyệt khác, hay trong trình duyệt nhúng của app Mail, là lần đổi không thể thành công. Trang lỗi giờ nói thẳng: mở link trên chính trình duyệt đã yêu cầu đăng nhập.
Trang callback còn hai chi tiết đáng chép lại:
// One attempt per page load: the code is single-use and StrictMode runs effects twice.
let attempt: Promise<'signed-in' | 'redirecting'> | null = null;
useEffect(() => {
if (!attempt) {
const href = window.location.href;
window.history.replaceState(null, '', window.location.pathname); // drop tokens from history
attempt = signInFromLink(href);
}
// …
}, []);StrictMode của React chạy effect hai lần ở môi trường dev, và một code dùng một lần bị đổi hai lần thì lần thứ hai fail. Một promise ở cấp module đảm bảo chỉ đổi một lần. replaceState xoá code khỏi thanh địa chỉ và history trước khi làm gì khác.
signInFromLink nhận ?code=, #access_token, ?token_hash=, và cả link verify gốc của Supabase dán vào form đăng nhập. Link dán vào chỉ được mở nếu nó trỏ tới đúng origin Supabase của mình. Mình định thêm mã 6 số vào template email, nhưng ở free plan, không có SMTP riêng thì template bị khoá, nên vẫn dùng link. Cuối ngày mình thêm đăng nhập bằng mật khẩu cho đúng một tài khoản admin, giữ email link làm phương án dự phòng.
Phía React, auth context phản chiếu session của supabase-js và mọi redirect đều declarative. Route guard phản ứng khi session thành null, còn đăng xuất thì xoá các query đã cache. Không chỗ nào gọi navigate() khi trạng thái auth đổi. Mỗi lần chuyển trạng thái chỉ một chỗ redirect, đúng luật đã sửa lỗi race lúc logout ở admin Saramin.
CSP vừa với antd trên host tĩnh
Bản Next.js có policy chặt: script-src 'self' 'nonce-…' 'strict-dynamic', nonce mới cho mỗi request do proxy tạo. Host tĩnh phục vụ cùng một nội dung cho mọi người, nên không có nonce theo request nào để tạo. Có hai lý do khiến chuyện này chấp nhận được.
Thứ nhất, script không cần nonce. Bản build production của Vite không có script inline nào, chỉ có file đã hash trong /assets/. Nên script-src 'self' trên thực tế chặt ngang policy cũ, mà đơn giản hơn.
Thứ hai, style đằng nào cũng không chặt được. Ant Design 5 dùng CSS-in-JS và chèn thẻ <style> lúc chạy. Ngay cả bản Next.js cũng cần style-src 'unsafe-inline', vì thuộc tính style={} của React không mang nonce được. Nên chẳng mất gì:
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: <project>.supabase.co; connect-src 'self' <project>.supabase.co wss://<project>.supabase.co; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
}connect-src ghi origin Supabase cho cả https lẫn wss, vì Realtime dùng websocket. img-src có nó cho các URL screenshot đã ký. Cạnh CSP, vercel.json gửi X-Robots-Tag: noindex, nofollow, noarchive, X-Frame-Options: DENY, HSTS, Referrer-Policy: no-referrer, một Permissions-Policy khoá chặt, no-cache cho HTML và immutable cho /assets/. vite preview gửi đúng các header đó, nên mình test được policy ở local trước khi deploy.
Bản thân console
Ant Design là lựa chọn đúng cho một công cụ có một người dùng và rất nhiều bảng. Console có sider thu gọn được, trang tổng quan với thẻ số liệu và biểu đồ theo ngày, và hộp thư có bộ lọc nằm trong URL: app, loại, trạng thái, tìm kiếm, khoảng ngày, install ID, sắp xếp, trang và cỡ trang. ?id= mở drawer chi tiết với message, screenshot qua URL ký một giờ, trạng thái, ghi chú riêng, trả lời qua email, copy thành GitHub issue, và xoá. Đổi trạng thái và xoá hàng loạt chạy trên các dòng được chọn, và khi xoá thì screenshot bị xoá trước. Dưới 768 px bảng chuyển thành thẻ, nên trên điện thoại không phải cuộn ngang.
Một subscription Realtime trên app_feedback invalidate mọi query góp ý khi có thay đổi, và chỉ báo "Trực tiếp" trên header cho biết kênh có đang kết nối không.
Checklist
- Liệt kê xem lớp server đang bảo vệ gì. Nếu Postgres đã ép mọi bước kiểm tra, ship file tĩnh.
- Coi anon key là public và làm RLS kín cho mọi role, không chỉ những đường UI dùng tới.
- Để allow-list trong một bảng không client nào đọc được, đứng sau function
security definercósearch_path = ''và kiểm cả email đã xác nhận. - Giới hạn update bằng quyền theo cột, không chỉ bằng policy theo dòng.
- Đưa policy của Storage và publication của Realtime vào cùng một luật.
- Test luật live dưới danh nghĩa anon, user không phải admin, và admin.
- Thêm mọi origin callback vào Redirect URLs. Thiếu một cái là fallback về Site URL và mất token.
- Xử lý
?code=của PKCE, mở link trên cùng trình duyệt, chỉ đổi một lần (StrictMode), và dọn URL. - Trên host tĩnh:
script-src 'self'với bản build không có script inline, và chấp nhậnstyle-src 'unsafe-inline'cho antd. - Thêm
noindex, chặn frame, HSTS và cache immutable cho asset trongvercel.json, rồi preview với cùng các header đó.
Bài liên quan


