-- CI check: bảng public nào chưa bật RLS?
select relname from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' and not c.relrowsecurity;
Mock: Internal Business Platform on Next.js + Supabase/Vercel
draftedBộ mock theo một JD thật — build internal business platform từ đầu trên Next.js + Node/TS + PostgreSQL, auth/RBAC/multi-tenant, Supabase (RLS, Auth, pooler) + Vercel là bắt buộc, agentic coding (Claude Code/Cursor), và ownership chất lượng end-to-end (schema, security, test, CI/CD, observability).
0/50 rated · mastery 0%
Bài học
5 bài · ~69 phút đọc. Học theo thứ tự, rồi làm câu hỏi.- 01~17 phút· 5 câuverify
Supabase từ góc nhìn bảo mật: roles, API keys và RLS
Supabase = Postgres + PostgREST + Auth: role anon/authenticated/service_role, publishable vs secret key, vì sao RLS là ranh giới thật, USING/WITH CHECK, helper security definer, view và materialized view. Mọi output chạy thật trên supabase/postgres 17.11 + PostgREST v16.4 + GoTrue v2.197.
- 02~14 phút· 5 câuverify
Supabase Auth, JWT claims và data model tenant + RBAC
JWT của Supabase Auth, app_metadata vs user_metadata, Custom Access Token Hook, claim stale, bảng memberships, composite FK, enum role → permission, UUID và timestamptz. Chạy thật với GoTrue v2.197 + hook trên supabase/postgres 17.11.
- 03~13 phút· 6 câuverify
RLS ở production: hiệu năng, test matrix và incident leak
Vì sao policy làm chậm, (select auth.uid()) và index, EXPLAIN dưới đúng role, pgTAP test matrix trong CI, soft delete với RLS, giờ đầu tiên của một incident leak. Số liệu chạy thật trên 2 triệu dòng, supabase/postgres 17.11.
- 04~12 phút· 6 câuverify
Next.js App Router + Supabase: session, proxy, Server Actions và OAuth
@supabase/ssr trong Server/Client Component, proxy.ts refresh session, getSession vs getUser vs getClaims (chạy thật với token giả mạo), Server Action là public endpoint, chọn browser/action/handler, Zod + generated types, Google OAuth PKCE và redirect allow-list.
- 05~13 phút· 4 câuverify
Supavisor, pooler mode và state trên serverless
Vì sao serverless làm cạn connection, direct vs session vs transaction mode, prepared statement và SET trong transaction mode (PgBouncer 1.26 chạy thật), state module-scope trên Fluid compute, cache theo tenant, job dài.
Tóm tắt & cheat sheet của track
TL;DR
- JD này chấm 4 thứ: (1) dựng internal platform từ đầu (data model + auth + permissions), (2) Supabase/Vercel là firm requirement, (3) dùng agent (Claude Code/Cursor) có kiểm soát, (4) tự chịu trách nhiệm chất lượng: schema integrity, security, test, CI/CD, observability, production readiness.
- Trục xuyên suốt là tenant isolation nhiều lớp: tenant lấy từ token đã verify → app check permission → Postgres RLS chặn ở DB → cache key/Storage path/Realtime cũng scoped theo tenant.
- Supabase = Postgres + Auth (GoTrue, JWT) + PostgREST + Realtime + Storage. Client gọi thẳng DB qua PostgREST bằng anon/publishable key → RLS là ranh giới bảo mật thật, không phải "defense in depth tùy chọn".
- Vercel = serverless/Fluid functions + preview deployment theo PR. Hệ quả: dùng connection pooler, không giữ state trong memory, job dài đưa ra queue/cron.
- Agentic coding: nói được workflow + guardrails (context file, plan trước, task nhỏ, test/validator làm gate, human review cho auth/migration/tiền).
Khái niệm cốt lõi
| Khái niệm | Một câu | Ví dụ ngắn |
|---|---|---|
| Row Level Security (RLS) | Postgres tự thêm điều kiện vào mọi query theo policy của role hiện tại | USING (tenant_id = (select private.current_tenant())) |
USING vs WITH CHECK | USING lọc row được thấy/sửa/xoá; WITH CHECK chặn row mới/sau update không hợp lệ | Chặn user đổi tenant_id của row sang tenant khác |
auth.uid() / auth.jwt() | Hàm Supabase đọc claim sub / toàn bộ claims của JWT request hiện tại | auth.jwt() -> 'app_metadata' ->> 'tenant_id' |
| anon/publishable vs service_role/secret key | Key public cho client (bị RLS) vs key server (bypass RLS) | service_role chỉ ở server, không bao giờ NEXT_PUBLIC_ |
app_metadata vs user_metadata | app_metadata chỉ server sửa; user_metadata user tự sửa được | Role/tenant không đặt trong user_metadata |
| Custom Access Token Hook | Postgres function chạy khi phát JWT, thêm claim (tenant, role) | Claim active_tenant_id (verify API hiện tại) |
| Membership table | Bảng nối user ↔ tenant ↔ role, nguồn sự thật cho RBAC | memberships(user_id, tenant_id, role) PK kép |
| Security definer function | Function chạy với quyền owner → bỏ qua RLS | Helper is_member(tenant) để tránh policy đệ quy; đặt ở schema private |
security_invoker view | View (PG15+) áp RLS theo người gọi thay vì owner | create view ... with (security_invoker = true) |
| Supavisor pooler | Pooler Postgres của Supabase; transaction mode cho serverless | Port 6543 transaction mode (verify) |
| Preview deployment | Mỗi PR có URL deploy riêng với env Preview | QA test PR trước khi merge |
| Expand → contract | Đổi schema qua nhiều release tương thích ngược | Add column → dual-write → backfill → switch read → drop |
Cơ chế hoạt động
Một request từ Next.js tới Supabase, qua các lớp kiểm soát
Điểm mấu chốt: proxy (middleware cũ) chỉ refresh session và redirect sớm; quyết định bảo mật nằm ở Server Component/Action và ở RLS. Nếu app check sót, RLS vẫn chặn. Nếu dùng service_role, RLS bị tắt, nên toàn bộ trách nhiệm quay về code.
Mô hình dữ liệu tenant + RBAC tối thiểu
Pipeline chất lượng cho một PR
Trade-offs
| Quyết định | Lựa chọn A | Lựa chọn B | Khi nào chọn |
|---|---|---|---|
| Truy cập DB | Client gọi Supabase trực tiếp (PostgREST + RLS) | Mọi thứ qua API server (Route Handler/Server Action) | A: CRUD đơn giản, realtime. B: logic nghiệp vụ phức tạp, nhiều bước, cần transaction |
| Chỗ enforce tenant | Chỉ app layer | App layer + RLS | Supabase gần như luôn B, vì client có thể gọi DB trực tiếp |
| Tenant trong JWT claim | Nhanh (không query), nhưng stale đến khi token refresh | Tra bảng membership mỗi query | Claim cho tenant đang active; membership table cho quyền nhạy cảm/cần revoke ngay |
| Tenancy model | Shared schema + tenant_id | Schema/DB riêng mỗi tenant | Internal platform nhiều tenant nhỏ: shared + RLS. Tenant lớn/compliance: tách |
| RBAC | Role cố định (enum) | Permission-based (role → permissions) | Bắt đầu enum đơn giản, chuyển permission khi có >3–4 role hoặc custom role |
| Chạy logic server | Server Action | Route Handler | Action: mutation từ form UI. Handler: webhook, API cho client ngoài, public contract |
| Function runtime | Node.js runtime | Edge runtime | Node: mặc định, đầy đủ lib, DB driver. Edge: logic nhẹ gần user (verify khuyến nghị hiện tại) |
Pitfalls
- ❌ Tạo bảng mới mà quên
enable row level security→ bảng mở toàn bộ cho anon key. ✅ Migration template luôn có RLS + policy; CI chạy query kiểm tra bảngpublicnào chưa bật RLS. - ❌ Đặt role/tenant trong
user_metadata. ✅app_metadata, membership table, hoặc access token hook. - ❌ Dùng
service_roletrong Server Component "cho tiện" → bypass RLS. ✅ Client theo user cho request của user; admin client chỉ cho job hệ thống, luôn filter tenant tường minh. - ❌ Chỉ check auth trong
proxy.ts/middleware. ✅ Check lại trong từng Server Action/Route Handler (Server Action là POST endpoint public). - ❌ Tin
getSession()ở server. ✅getUser()/getClaims()để verify token (verify theo docs Supabase hiện tại). - ❌ Policy gọi
auth.uid()trên từng row, không indextenant_id. ✅(select auth.uid())và index cột trong policy. - ❌ View thường trên bảng có RLS → view chạy quyền owner, lộ data. ✅
security_invoker = truehoặc revoke quyền view. - ❌ Kết nối Postgres trực tiếp từ mỗi serverless invocation. ✅ Pooler transaction mode, pool nhỏ mỗi instance.
- ❌ Để agent sinh migration/RLS rồi merge vì test pass. ✅ Đọc từng dòng, chạy test matrix cross-tenant.
Cheat sheet
- Tenant isolation = token verify → app permission → RLS → cache key/Storage path/Realtime channel có tenant.
- Mọi bảng
public:tenant_id NOT NULL+ FK + index + RLS bật + policy SELECT/INSERT/UPDATE/DELETE rõ ràng. - Policy INSERT/UPDATE cần
WITH CHECK; UPDATE còn cần row nhìn thấy được qua SELECT policy. - Helper
security definerđặt ở schema không expose qua API,set search_path = ''. - Next.js: Server Action = public endpoint; proxy chỉ refresh session;
NEXT_PUBLIC_*lộ ra browser. - Vercel: env theo Production/Preview/Development; preview dùng DB riêng, không dùng DB prod; instant rollback chỉ an toàn khi migration là expand-only.
- Migration Postgres an toàn:
lock_timeout,CREATE INDEX CONCURRENTLY, constraintNOT VALID→VALIDATE, backfill theo batch. - Test: RLS test matrix (role × action × same/other tenant) chạy trên Postgres thật trong CI.
- Observability: log có
request_id,tenant_id,user_id; alert theo error rate + p95 + slow query. - Agent: context file → plan → task nhỏ → test/validator gate → human review cho auth/migration/tiền.
References
- Supabase docs: Row Level Security ↗
- Supabase docs: Server-side Auth for Next.js ↗
- Supabase docs: Custom Access Token Hook ↗
- Supabase docs: Connecting to Postgres (pooler) ↗
- PostgreSQL docs: Row Security Policies ↗
- Next.js docs: Data Security ↗
- Vercel docs: Environments ↗
- Anthropic: Claude Code best practices ↗
Interview Questions
Showing 50 / 50
create policy "members update own tenant rows" on projects
for update to authenticated
using (private.is_member(tenant_id))
with check (private.is_member(tenant_id));
create function private.has_role(t uuid, roles text[])
returns boolean language sql stable security definer set search_path = '' as $$
select exists (
select 1 from public.memberships m
where m.user_id = (select auth.uid()) and m.tenant_id = t and m.role = any(roles)
);
$$;
alter table projects enable row level security;
create policy "read" on projects for select to authenticated
using (private.has_role(tenant_id, '{owner,admin,member,viewer}'));
create policy "write" on projects for insert to authenticated
with check (private.has_role(tenant_id, '{owner,admin}'));
create policy "update" on projects for update to authenticated
using (private.has_role(tenant_id, '{owner,admin}'))
with check (private.has_role(tenant_id, '{owner,admin}'));
create policy "tenant read" on orders for select to authenticated
using (
tenant_id in (
select tenant_id from memberships where user_id = auth.uid()
)
);
-- orders has no index on tenant_id
-- app query: select * from orders order by created_at desc limit 50;
create view public.order_totals with (security_invoker = true) as
select tenant_id, date_trunc('day', created_at) d, sum(total) s
from orders group by 1, 2;
'use server'
import { z } from 'zod'
import { requireMember } from '@/server/auth'
const Input = z.object({ projectId: z.uuid(), name: z.string().min(1).max(120) })
export async function renameProject(raw: unknown) {
const input = Input.parse(raw)
const { supabase } = await requireMember({ permission: 'project:update' })
const { error } = await supabase.from('projects')
.update({ name: input.name }).eq('id', input.projectId)
if (error) throw error
}
begin;
select plan(2);
set local role authenticated;
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
select is_empty($$ select * from invoices where tenant_id = 'tenant-b-uuid' $$, 'A cannot read B');
select throws_ok($$ insert into invoices(tenant_id, total) values ('tenant-b-uuid', 10) $$, '42501');
select * from finish();
rollback;
-- 20261001_add_external_ref.sql
alter table tasks add column external_ref uuid not null default gen_random_uuid();
create index tasks_external_ref_idx on tasks (external_ref);
alter table tasks add constraint tasks_project_fk
foreign key (tenant_id, project_id) references projects (tenant_id, id);
create extension if not exists btree_gist;
alter table shifts add constraint no_overlap exclude using gist (
tenant_id with =, employee_id with =,
tstzrange(starts_at, ends_at, '[)') with &&
);
// lib/stats.ts (server)
let cached: { at: number; data: Stats } | null = null
export async function getStats(tenantId: string) {
if (cached && Date.now() - cached.at < 60_000) return cached.data
const data = await db.query('select ... where tenant_id = $1', [tenantId])
cached = { at: Date.now(), data }
return data
}
export async function POST(req: Request) {
const body = (await req.json()) as { tenantId: string; role: 'member' | 'admin'; email: string }
const admin = createAdminClient() // service_role
await admin.from('memberships').insert({
tenant_id: body.tenantId, role: body.role, email: body.email,
})
return Response.json({ ok: true })
}
export async function approve(requestId: string) {
const { data: req } = await supabase.from('expense_requests')
.select('*').eq('id', requestId).single()
if (req.status !== 'pending') throw new Error('Already processed')
await supabase.from('expense_requests').update({ status: 'approved' }).eq('id', requestId)
await createPayout(req) // inserts into payouts
}