RLS와 사용자 식별
RLS를 켜면 모든 env.db 문장이 코드에 들어온 주체로 실행됩니다. 로그인한 요청은 그 사용자로 실행되므로, 코드에 신원을 넘기지 않아도 runlot_auth.uid()를 쓰는 행 수준 보안 정책이 걸러 냅니다.
사용자 테이블은 여러분의 것입니다
사용자 테이블은 프로젝트 데이터베이스의 runlot_auth 스키마에 있습니다. 일반 테이블처럼 조인하여 사용할 수 있습니다.
select p.id, p.title, u.email
from posts p
join runlot_auth.users u on u.id = p.owner_id
order by p.id desc
limit 20;마이그레이션의 외래 키
앱이 아직 요청을 한 번도 받지 않은 새 프로젝트에서도 마이그레이션이 runlot_auth.users 를 바로 참조할 수 있습니다.
-- migrations/0002_posts.sql
create table posts (
id bigserial primary key,
owner_id uuid not null references runlot_auth.users(id) on delete cascade,
title text not null
);적용할 마이그레이션에 runlot_auth 가 들어 있으면 runlot pg migrate 와 runlot deploy 가 같은 연결에서 스키마를 먼저 설치하고 auth schema ensured 를 출력합니다. 프로젝트의 로그인 기능이 켜져 있어야 하며, 꺼져 있으면 마이그레이션이 멈추고 그 이유를 알려 줍니다.
RLS 켜기
RLS는 기본으로 꺼져 있고, 그때 env.db 는 어디서나 프로젝트 소유자로 실행됩니다. runlot.json 에서 켜면 runlot deploy 가 적용합니다.
{
"auth": { "rls": true }
}CLI나 대시보드 앱 로그인 탭의 RLS 스위치로도 켤 수 있습니다.
runlot auth set rls on
runlot auth status # RLS On 으로 표시됩니다문장은 누구로 실행되는가
RLS가 켜져 있으면 플랫폼이 코드에 들어온 경로를 보고 데이터베이스 역할을 고릅니다. 코드가 신원을 지정하지 않으므로 다른 사람으로 가장할 수 없습니다.
| 진입점 | env.db 실행 주체 |
|---|---|
로그인한 env.auth 세션(쿠키 또는 Bearer)이 있는 fetch | 그 사용자(runlot_authenticated). runlot_auth.uid() 는 사용자 id |
세션이 없거나 유효하지 않은 fetch | 익명(runlot_anon) |
scheduled (크론) | 소유자 |
queue | 소유자 |
| Durable Object 알람 | 소유자 |
email (수신 메일) | 익명(runlot_anon) |
ctx.waitUntil 과 스트리밍 본문 | 그것을 시작한 요청의 역할 |
마이그레이션, runlot pg shell, runlot pg execute | 소유자 |
열기 전까지는 닫혀 있습니다
RLS가 켜져 있으면 앱 사용자가 닿을 수 있는 테이블은 여러분이 일부러 연 테이블뿐입니다.
| 테이블 | 로그인 사용자나 익명 사용자가 받는 결과 |
|---|---|
| 행 수준 보안 없음 | 42501 permission denied |
| 행 수준 보안 있음, 정책 없음 | 행 없음. 쓰기는 거부됩니다 |
| 행 수준 보안과 정책 있음 | 정책이 허용하는 만큼 |
마이그레이션이 끝나면 runlot pg migrate 와 runlot deploy 가 권한을 맞추고, 아직 닫혀 있는 테이블을 하나씩 알려 줍니다.
RLS grants synced: 1 table open to app users
drafts has no row level security — closed to app usersrunlot_auth 자체는 앱 사용자에게 절대 열리지 않습니다. 사용자 정보는 env.auth 로 읽으세요.
정책이 있는 마이그레이션
-- migrations/0003_posts_rls.sql
create table posts (
id bigserial primary key,
owner_id uuid not null default runlot_auth.uid() references runlot_auth.users(id) on delete cascade,
title text not null,
created_at timestamptz not null default now()
);
alter table posts enable row level security;
-- 각 사용자는 자기 글만 읽고 씁니다.
create policy own_posts on posts
using (owner_id = runlot_auth.uid())
with check (owner_id = runlot_auth.uid());
-- 로그인하지 않은 방문자도 모든 글을 읽을 수 있습니다. 글을 비공개로 두려면 빼세요.
create policy public_read on posts for select to runlot_anon using (true);default runlot_auth.uid() 가 작성자를 채우므로 insert에서 넘길 필요가 없습니다.
export default {
async fetch(request, env) {
if (request.method === "POST") {
const { title } = await request.json();
await env.db.exec("insert into posts (title) values ($1)", [title]);
}
return Response.json(await env.db.exec("select id, title from posts order by id desc"));
},
async scheduled(event, env) {
// 소유자로 실행되어 모든 행을 봅니다.
await env.db.exec("delete from posts where created_at < now() - interval '1 year'");
},
};코드 어디에도 사용자 id가 없습니다. fetch 에서 나온 모든 문장을 정책이 걸러 냅니다.
함수 정의는 다음과 같습니다.
create or replace function runlot_auth.uid() returns uuid language sql stable as $$
select (nullif(current_setting('request.jwt.claims', true), '')::json->>'sub')::uuid
$$;request.jwt.claims 는 PostgREST와 Supabase가 쓰는 것과 같은 설정이므로, 그쪽에서 작성한 정책이 그대로 동작합니다.