# task-2991 조사보고서: InsuRo 상담 RLS caller-binding — 클레임 주입 경로 설계 근거

**범위**: READ-ONLY 조사. 코드 수정 없음. 참고 리포: `/home/jay/projects/InsuRo/.worktrees/task-2991-dev3` (읽기 전용)
**담당**: dev3 아키텍처 리서치

---

## A. 클레임 주입 경로 후보

### A-1. Edge Function이 직접 JWT 서명 발급

**동작원리**: Edge Function이 프로젝트 JWT secret(또는 새로 import한 signing key)으로 커스텀 페이로드(`role`, `sub`, `chat_token` 등)를 서명한 JWT를 만들어 클라이언트에 반환. 클라이언트는 이후 `Authorization: Bearer <이 JWT>` 로 PostgREST를 호출.

**근거**:
- [Securing Edge Functions](https://supabase.com/docs/guides/functions/auth) — Edge Function 내부에서 `Authorization: Bearer <JWT>` 헤더가 Data API(PostgREST) 요청에 그대로 전달돼 인증에 쓰인다는 공식 설명.
- [JWT Signing Keys](https://supabase.com/docs/guides/auth/signing-keys) — "If you wish to make your own JWTs or have access to the private key or shared secret used by Supabase, you can create a new JWT signing key by importing a private key" — **새 비대칭 키(signing keys) 체제에서도 개인키를 "import"하면 커스텀 서명이 가능**하다고 명시. 단, 신체제로 마이그레이션한 프로젝트는 **레거시 JWT secret 자체를 추출할 수 없음** — 반드시 새 키를 "import"해서 별도 signing key로 등록해야 함.
- 같은 문서: `role` 클레임은 `anon`/`authenticated`/`service_role` 등 **실제 Postgres 롤 이름**과 일치해야 PostgREST가 올바르게 처리 — 커스텀 JWT는 보통 `role: "authenticated"` + 커스텀 클레임(`chat_token` 등)을 함께 넣음.

**장점**: RLS 표현식이 표준 `auth.jwt()`로 단순해짐. `auth.uid()`가 실제 유저가 아니어도 `role: authenticated`로 동작 가능.

**단점**: (1) Edge Function **배포**가 필수 전제. (2) JWT secret/signing key 접근권한이 필요 — 잘못 다루면 전체 프로젝트 JWT 위조 리스크. (3) 새 비대칭 키 체제라면 "import" 작업(신규 키 등록)이 별도로 필요해 배포보다 상위의 프로젝트 설정 변경.

**이 프로젝트 적용 가능성**: C·D 절 참조 — **배포 수단 자체가 이 환경에 없음(확인됨)**.

---

### A-2. Custom Access Token Auth Hook

**동작원리**: `supabase.auth.signInAnonymously()` 등으로 세션을 만들면, 토큰 발급 직전에 Postgres 함수(Hook)가 호출되어 클레임을 가공할 수 있음.

**근거**:
- [Custom Access Token Hook](https://supabase.com/docs/guides/auth/auth-hooks/custom-access-token-hook) — 함수 시그니처: `create or replace function public.custom_access_token_hook(event jsonb) returns jsonb`. Hook 입력은 **`user_id`, `claims`(발급 예정 클레임 집합), `authentication_method`** 세 개뿐 — **외부 클레임 소스(예: URL의 채팅 토큰 문자열)를 직접 받지 않는다.** DB를 조회하거나 이미 클레임에 들어있는 값(예: `event->'claims'->'app_metadata'`)만 가공 가능.
- 익명 로그인이 **필수는 아님** — 문서 예시는 password/SSO 등 모든 로그인 방식에서 동작. 다만 이 프로젝트처럼 계정 없는 익명 고객에게 쓰려면 익명 로그인이 **사실상 유일한 세션 획득 수단**이 됨.
- [Anonymous Sign-Ins](https://supabase.com/docs/guides/auth/auth-anonymous) — "enabling anonymous sign-ins takes one toggle in the dashboard" — **Dashboard 토글**(Cloud 기준), self-hosted는 토글 없음. 프로젝트 설정 변경이며 CLI 함수 배포와는 다른 채널.
- Postgres Hook(HTTP Edge Function 아닌 SQL 함수) 등록: `config.toml`에 `[auth.hook.custom_access_token] enabled = true, uri = "pg-functions://postgres/public/custom_access_token_hook"` + `grant execute on function ... to supabase_auth_admin;` — **Edge Function 배포가 필요 없는 변형**으로 참고 URL: Supabase CLI config 문서(`pg-functions://` URI 스킴), 검색 결과 확인.

**핵심 결함(설계상 반드시 짚어야 함)**: Hook이 그대로 통과시킬 수 있는 것은 클레임 오브젝트 안의 값뿐인데, 그 값이 **`user_metadata`면 로그인한 유저 본인이 `updateUser()`로 자유롭게 고쳐 쓸 수 있는 값**이다. 즉 클라이언트가 익명 로그인 후 `user_metadata.chat_token`을 **타인의 토큰 문자열로 직접 세팅**해버리면 Hook은 그걸 그대로 클레임에 복사해 RLS를 통과시킨다.
- 근거: RLS 성능/베스트프랙티스 웹서치 결과(공식 자료 종합) — *"Not all information present in the JWT should be used in RLS policies. For instance, creating an RLS policy that relies on the user_metadata claim can create security issues in your application as this information can be modified by authenticated end users."*
- **단, 이 프로젝트의 위협모델(링크 기반 접근 — URL에 토큰 문자열을 아는 사람이면 누구나 접근 가능한 "capability URL" 모델)에서는 "토큰 문자열 자체가 자격증명"이므로, 문제는 "user_metadata를 직접 세팅"이 아니라 "그 세팅이 서버측 토큰 존재/활성 여부 검증 없이 통과되는가"이다.** `user_metadata`에 그냥 넣게 하면 **존재하지도 않는 토큰 문자열**도 클레임에 실려버려 RLS 표현식에서 반드시 `EXISTS(SELECT 1 FROM customer_chat_tokens WHERE token = claim AND is_active)`처럼 **DB 존재 확인을 곁들여야** 안전. 반대로 `app_metadata`는 **service_role만 쓸 수 있어** 클라이언트가 위조할 수 없다 — 즉 **바인딩을 client-writable한 `user_metadata`가 아니라 server-write-only `app_metadata`로 하는 것이 진짜 안전한 설계**다. 그런데 `app_metadata`를 서버측에서 쓰려면 `service_role` 키를 쓰는 어떤 서버 컴포넌트(Edge Function 또는 SECURITY DEFINER RPC)가 반드시 필요 — 결국 "Hook 단독"으로는 안전한 바인딩이 안 되고, 바인딩 단계는 RPC/Edge Function 둘 중 하나가 맡아야 한다.

**이 프로젝트 적용 가능성**: 익명 로그인 활성화(Dashboard 토글) + Hook 등록(Auth 설정, Dashboard/Management API) — **둘 다 코드 배포가 아니라 프로젝트 설정 변경**이며, 이 환경에서 Management API를 두드릴 `SUPABASE_ACCESS_TOKEN`이 없으므로(D절) **사람이 Dashboard에서 수동으로 켜야** 함.

---

### A-3. 기타 후보

**RPC(SECURITY DEFINER) — Edge Function 없이 클레임도 없이 좁히기**: 클레임 주입을 아예 우회하고, `customer_chat_tokens`/`conversations`/`conversation_messages`에 대한 **anon 직접 SELECT/INSERT 정책을 전부 제거**한 뒤, `get_conversation_by_token(p_token text)`, `post_customer_message(p_token text, p_content text)` 같은 **SECURITY DEFINER Postgres 함수**만 anon에게 `EXECUTE` 권한을 준다. 함수 내부에서 `WHERE token = p_token AND is_active` 를 SQL로 직접 검증 — 클라이언트는 여전히 `anon` 키를 쓰지만 `.from(...)` 대신 `.rpc(...)`를 호출.
- 근거: [Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security) — RLS는 "Once RLS is enabled, no data is accessible through the API when using a publishable key, until you create policies" — 즉 **정책을 아예 안 만들면 default-deny**. SECURITY DEFINER 함수는 함수 소유자 권한으로 실행되어 테이블 소유자/슈퍼유저 컨텍스트에서는 RLS를 우회함(Postgres 표준 동작) — 이 프로젝트에 **이미 7건의 SECURITY DEFINER 함수 선례**가 있음(C절 참조).
- **장점**: JWT/Hook/Edge Function 어떤 것도 필요 없음. 순수 `.sql` 마이그레이션 + 프론트 호출부 변경만으로 완결. 클레임 위조·`auth.jwt()` NULL 버그(아래 B절) 등 JWT 경로의 알려진 함정을 원천 차단.
- **단점**: ANU·Codex가 합의한 "JWT 클레임" 설계 그 자체는 아님 — **대안 설계**로 명시해야 함(단, "기각된 대안"인 "service_role 전면 프록시"와는 다름 — 이건 매 요청을 대신 수행하는 프록시가 아니라 anon이 직접 호출하는 **범위 제한된 파라미터화 RPC**).

---

## B. RLS 클레임 참조 문법 + fail-closed 검증

**표준 문법**:
```sql
-- 클레임 하나 추출
(select auth.jwt() ->> 'chat_token')
-- 또는 GUC 직접 참조 (동일 값)
(select nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'chat_token')
```

**NULL/fail-closed 동작**: 클레임이 없으면 `auth.jwt()->>'chat_token'`은 SQL `NULL`. `token = NULL`은 **항상 `NULL`**(TRUE도 FALSE도 아님) → PostgreSQL RLS의 `USING`은 결과가 **TRUE가 아니면 그 행을 제외** → 클레임 부재 시 자동으로 **fail-closed**. 근거: 공식 문서 인용 — `"null = user_id" is always false in SQL` (Row Level Security 가이드), 같은 원리가 `auth.jwt()` 클레임 비교에도 적용됨.

**★ 실무 함정(반드시 짚을 것)**: fail-closed는 **"그 정책 하나"에 대해서만** 성립한다. PostgreSQL RLS는 같은 커맨드(SELECT 등)에 대해 **여러 PERMISSIVE 정책이 있으면 OR로 결합**한다. 지금 InsuRo의 `conversations`/`conversation_messages`에는 **기존에 `USING (true)` 짜리 permissive 정책이 이미 존재**(`customer_chat_tokens`는 현재도 `FOR SELECT TO anon USING (true)` 그대로 — C절 확인). 새 클레임 기반 정책을 "추가"만 하면 기존 `USING(true)`와 OR로 묶여 **아무 효과가 없다.** 반드시 **기존 permissive `true` 정책을 DROP**하거나, 새 정책을 `AS RESTRICTIVE`로 선언해 AND로 결합해야 한다.

**알려진 버그(운영 리스크로 명시할 것)**: GitHub 이슈 [#42235](https://github.com/supabase/supabase/issues/42235), [#43066](https://github.com/supabase/supabase/issues/43066) — `auth.jwt()`/`current_setting('request.jwt.claims', true)`가 **RLS 정책 평가 컨텍스트에서 NULL을 반환**하는 사례가 보고됨(RPC 컨텍스트에서는 정상 작동하는데 RLS 컨텍스트에서만 NULL). fail-closed 자체는 안전(접근 거부)이지만, **정상 사용자도 거부당하는 가용성 장애**로 이어질 수 있어 배포 후 실제 anon 키로 프로덕션 REST 직접 프로브(★ 기존 팀 원칙과 일치)가 필수.

**성능(InitPlan)**: `auth.uid()`/`auth.jwt()`를 정책에 직접 쓰면 **행마다 재평가**됨. `(select auth.jwt())`처럼 서브쿼리로 감싸면 Postgres가 **InitPlan으로 캐싱**해 쿼리당 1회만 평가 — 100K행 테이블 기준 "5ms vs 5초" 차이로 문서화됨. 근거: [RLS Performance and Best Practices](https://supabase.com/docs/guides/troubleshooting/rls-performance-and-best-practices-Z5Jjwv). **결론: `WHERE token = (select auth.jwt()->>'chat_token')` 형태로 반드시 서브쿼리 래핑.**

또한 같은 문서군 공식 권고: *"Never use RLS involving auth.uid() or auth.jwt() as your only way to rule out 'anon' role. Always add 'authenticated' to the approved roles"* — 정책을 `TO authenticated`로 명시하고 `anon` 롤 자체의 권한을 좁히는 것도 병행 권고.

---

## C. 프로젝트 현황 (코드 근거)

**Edge Function**: `/home/jay/projects/InsuRo/.worktrees/task-2991-dev3/supabase/functions/`에 **12개 함수가 이미 코드로 존재**(`generate-content`, `create-test-user`, `premium-chat`, `newsletter-chat` 등). `supabase/config.toml`에 각 함수별 `verify_jwt = false` 설정만 있고, **JWT 서명·Auth Hook 관련 설정은 전혀 없음**. `create-test-user/index.ts`에서 `Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")` 사용 패턴 확인(Supabase가 Edge Function 런타임에 자동 주입하는 값 — 별도 조달 불필요) — 단 JWT secret을 직접 다루는 코드는 0건(`grep JWT_SECRET/jsonwebtoken/jose/SignJWT` 전부 0건).

**클라이언트 아키텍처**: `src/integrations/supabase/client.ts` — 앱 전역 **단일 supabase 클라이언트**를 `VITE_SUPABASE_PUBLISHABLE_KEY`(anon key)로 생성, `auth: { storage: localStorage, persistSession: true, autoRefreshToken: true }`. `src/pages/CustomerChat.tsx`의 `handleVerify` 흐름은 **`supabase.from("customer_chat_tokens").select(...).eq("token", token)`처럼 PostgREST를 anon 키로 직접 호출**하고, 애플리케이션 코드가 `.eq("token", token)`으로 필터링 — **세션/JWT 클레임 개념이 지금은 아예 없음**. JWT 클레임 방식을 쓰려면 이 부분에서 `handleVerify` 성공 직후 `supabase.auth.setSession(...)`(A-1) 혹은 `signInAnonymously()` + RPC로 `app_metadata` 바인딩 후 `refreshSession()`(A-2)을 호출하도록 **클라이언트 흐름 자체를 새로 설계**해야 함.

**현재 RLS 실측(코드로 확인, `_backup/task-2991_pg_policies_before.md` + 마이그레이션 대조)**:
- `customer_chat_tokens`: `CREATE POLICY "Anon can read chat tokens" ON public.customer_chat_tokens FOR SELECT TO anon USING (true);` — **지금도 이 정책이 살아있음**(anon이면 누구나 전체 토큰 행 직독 가능 — ANU가 기록한 신규 CRITICAL과 일치).
- `conversations`/`conversation_messages`: `20260309160000_db-safety-improvements.sql`에서 `USING(true)`를 걷어내고 `EXISTS(... JOIN customer_chat_tokens cct ... WHERE cct.is_active = true)`로 강화된 이력이 있으나, **이 조건은 "그 고객에게 활성 토큰이 존재하는가"만 확인할 뿐 "요청자가 실제로 그 토큰을 아는가"는 전혀 확인하지 않는다** — 즉 anon 키만 있으면 활성 상담이 있는 모든 고객의 대화를 읽을 수 있는 구조적 결함이 지금도 남아있음. 이게 task-2991이 풀어야 할 핵심 갭.

**배포 수단**: `.github/workflows/`에 `taskctl-ci.yml`, `diagnostic-pytest.yml`, `extension-release.yml`, `e2e.yml`, `ci.yml` 5개 존재하나 **Supabase Edge Function 배포 workflow 없음**(grep 0건). `package.json`에도 supabase deploy 스크립트 없음. `ops/`엔 `deploy.conf`, `insuro-api.service.new`만 있고 Supabase 관련 없음.

---

## D. 배포 제약과 현실적 대안

**Edge Function 배포 수단 확인**:
- `supabase` CLI는 PATH에 없지만 `npx supabase --version` → `2.115.0` 정상 동작(온디맨드 실행 가능, 설치 자체는 문제 없음).
- **그러나 인증 토큰(`SUPABASE_ACCESS_TOKEN`, `sbp_` 접두)이 이 환경 어디에도 없음**: `~/.supabase/`에는 `telemetry.json`과 `traces/`뿐, access-token 파일 없음. `.env`/`.env.production` key 이름 목록에도 `SUPABASE_ACCESS_TOKEN` 없음(`SUPABASE_DB_PASSWORD`, `INSURO_NEW_SERVICE_ROLE_KEY`, `INSURO_NEW_SUPABASE_URL`은 있으나 Management API/CLI 인증에는 쓸 수 없음).
- **★ 실측 선례**: `/home/jay/workspace/memory/reports/task-2128.md`(비슈누팀, MERGED) — 정확히 이 문제로 **Edge Function 5개 배포가 차단**된 이력. "Service role key 있으나 Management API 인증 불가(401 JWT failed verification)", "`~/.supabase/`, `.env*`, bash history 전체 검색 결과 토큰 없음"이라고 명시. 해결책으로 "Dashboard(https://supabase.com/dashboard/account/tokens)에서 PAT 생성 필요"만 제시된 채 **미해결로 종료**. 오늘(task-2991) 재확인해도 동일하게 토큰 부재 — **이 문제는 아직도 안 풀려 있다.**

**결론: Edge Function 방식(A-1) 및 Auth Hook 활성화(A-2의 Dashboard 토글/Management API 부분)는 이 환경에서 봇이 자율적으로 실행할 수 없다.** 둘 다 (a) `SUPABASE_ACCESS_TOKEN` 확보 또는 (b) 회장님이 직접 Supabase Dashboard에 로그인해 수동으로 처리 — 둘 중 하나가 선행돼야 함.

**"Edge Function 없이 DDL만으로 좁힐 수 있는가?" — 답: 가능하다. (A-3, RPC 경로)**
- `customer_chat_tokens`/`conversations`/`conversation_messages`에 대한 anon 대상 직접 SELECT/INSERT/UPDATE 정책을 전부 제거(또는 `REVOKE`)하고, 대신 파라미터화된 `SECURITY DEFINER` RPC 함수(`get_conversation_by_token(p_token)`, `post_customer_message(p_token, p_content)`, `list_messages_by_token(p_token)` 등)만 anon에 `EXECUTE` 허용.
- 함수 본문 안에서 `WHERE token = p_token AND is_active = true`를 SQL로 직접 검증 — **JWT 클레임도, Hook도, Edge Function도 필요 없음.**
- 이 패턴은 **이미 이 리포에 7건의 `SECURITY DEFINER` 함수 선례**가 있어(`20260308064438_...sql`, `20260420400000_wiki_performance_rpc.sql` 등) 신규 패턴이 아님.
- 배포 채널도 이미 검증됨: 워크스페이스 메모리에 기록된 **"Supabase DDL = psycopg2 + `aws-1` 풀러"** 경로(`feedback_supabase_prod_ddl_path_pooler_psycopg2_260817.md`)로 마이그레이션 SQL을 프로덕션에 직접 적용 가능 — Management API/CLI 토큰 불필요.
- 프론트 변경은 `src/pages/CustomerChat.tsx` 등에서 `.from("conversations").select(...)` → `.rpc("get_conversation_by_token", { p_token: token })` 로 호출부 교체(순수 클라이언트 코드, Edge Function 아님).
- **주의**: `SECURITY DEFINER` 함수는 `search_path`를 함수 정의에서 명시적으로 고정(`SET search_path = public, pg_temp`)해야 함 — 안 하면 search_path 하이재킹으로 함수가 예상 밖 테이블을 참조하게 되는 알려진 Postgres 취약점.

**주의 — "기각된 대안"과 혼동 금지**: 이 RPC 경로는 "Edge Function/service_role 전면 프록시"(기각됨, 매 요청을 대신 수행하는 별도 서버 프로세스)와 **다르다**. `anon` 키가 여전히 PostgREST를 직접 호출하고, 다만 테이블이 아니라 파라미터화된 RPC를 호출할 뿐이다. 헤더/GUC 참조(기각됨)와도 무관 — 토큰은 함수 인자로 명시 전달된다.

---

## E. 권고안

**1순위(즉시 실행 가능) — A-3 SECURITY DEFINER RPC 방식을 권고한다.**

이유:
1. **배포 제약이 없다.** ANU·Codex 합의 설계(A-1/A-2)는 둘 다 Supabase Dashboard/Management API 접근(`SUPABASE_ACCESS_TOKEN` 또는 사람의 수동 조작)을 전제하는데, 이 환경엔 그 수단이 **없고, task-2128에서 이미 한 번 이 이유로 막힌 실측 선례**가 있다. 실행 불가능한 설계를 밀어붙이면 또 같은 자리에서 막힌다.
2. **보안 속성은 동등하거나 더 낫다.** RPC 경로는 `auth.jwt()`가 RLS 컨텍스트에서 NULL을 반환하는 알려진 버그(GitHub #42235/#43066)에 노출되지 않고, `user_metadata` 클라이언트 위조 문제도 원천적으로 없다(토큰은 함수 인자로 서버가 직접 검증).
3. **선례가 이미 있다.** 7건의 `SECURITY DEFINER` 함수, `psycopg2+pooler` DDL 배포 경로 둘 다 이 워크스페이스에서 검증된 채널.

**실행 불가 리스크(명시)**: 이 권고안은 **ANU·Codex가 합의한 "JWT 클레임" 설계 그 자체가 아니라 기능적으로 동등한 대안**이다. 만약 회장님/ANU가 문자 그대로 "JWT 클레임"이어야 한다고 고수하면, 선결 조건은 다음 둘 중 하나이며 **둘 다 이 봇 환경 밖의 조치가 필요**하다:
   - (a) Supabase Dashboard → Account → Access Tokens에서 `SUPABASE_ACCESS_TOKEN`(PAT) 발급 후 `.env`/CI 시크릿에 등록 (A-1 경로 활성화), 또는
   - (b) 회장님이 Dashboard에서 "Anonymous sign-ins" 토글을 켜고 Custom Access Token Hook을 수동 등록 (A-2 경로 활성화, Hook 함수 SQL 자체는 DDL로 사전 배포 가능하나 **활성화 스위치는 Dashboard/Management API 전용**).

이 두 선결 조건이 채워지기 전까지는 **A-1/A-2는 설계는 맞지만 이 환경에서 착수 불가능**이며, RPC 경로(A-3)만이 지금 바로 구현·배포·검증까지 완결 가능한 유일한 옵션이다.
