본문으로 건너뛰기
runlot

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 users

runlot_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가 쓰는 것과 같은 설정이므로, 그쪽에서 작성한 정책이 그대로 동작합니다.

이 페이지의 목차