명시적 신원 전달 계약 (이슈 #1033)
원칙 (오너 결정 2026-09-01): 함수는 "누구 것을 다룰지"를 로그인 컨텍스트에서 몰래 읽지 말고 인자로 받는다. 신원(
auth.uid())은 문(클라이언트가 호출하는 공개 RPC 진입점)에서 딱 한 번 읽는다. 다른 주체(서버 배치·관리자·다른 유저 화면)가 계산을 쓰기 위해 "유저인 척"(set_config('request.jwt.claim.sub', …)) 신원을 바꾸는 일은 서버 어디에도 없다.
친구·그룹 접근 계약(이슈 #1028)이 이 원칙의 1단(친구/그룹 읽기)이었다. 이 문서는 그 원칙을 앱 전체로 일반화한 상위 계약이다.
1. 세 가지 자리
| 자리 | auth.uid() | 신원 전환 | 규칙 |
|---|---|---|---|
| 문(door) — 클라이언트 실행 권한이 있는 공개 RPC | 본문에서 정확히 1회 | 금지 | 문에서 행위자를 읽고 게이트(본인/관리자/특권)를 검사한 뒤, 안쪽에는 명시 인자로 전달한다. 시그니처 DEFAULT 식에서의 auth.uid()도 금지 — 폴백은 본문에서 읽은 행위자로 |
| 내부(engine/core) — 클라이언트 실행 권한 없음 | 금지 | 금지 | 대상(p_user_id)·열람자(p_viewer)는 전부 명시 인자. 게이트 없음 — 호출자(문·트리거·정의자 체인)가 책임진다 |
| 트리거·배치·cron | 금지 | 금지 | 행 데이터(NEW/OLD.user_id)·작업표(job.user_id)의 명시 값으로 내부를 직접 호출한다 |
- 역할이 둘이면 인자도 둘: "누구의 데이터"(target)와 "누가 보는가"(viewer)는 별개 인자다 (근거: 2026-08-26 제3자 노출 사고 — friend-access.md 참조).
- 진입 게이트의 특권 판정(
request.jwt.claim.role/session_user기반 privileged 검사)은 문에서만 둔다. 내부에서의 특권 판정은 "호출자를 못 믿는다"는 뜻이고, 그 불신이 신원 흉내를 낳았다.
2. 의도된 임퍼서네이션 (이 계약의 대상 아님)
관리자 페이지의 "유저로 보기" 기능은 가장(임퍼서네이션) 자체가 목적(그 유저의 화면을 그대로 재현)인 앱/JWT 층 기능이다 — DB 함수 안의 신원 전환과 층위가 다르며 이 계약의 금지 대상이 아니다. DB 층에서 새 임퍼서네이션이 필요해 보이면, 그것은 이 계약 위반이 아니라 설계 오류 신호다(명시 인자로 풀 수 있다).
3. 적용 장부 (2026-09-01 Production 실측 기준)
신원 전환 사용처 — 트랙 전 11함수 → 후 0
| 함수 | 부류 | 처리 (Phase) |
|---|---|---|
| process_user_exercise_stats_refresh_jobs | 통계 배치(문) | 전환 제거 (1) |
| process_user_exercise_stats_refresh_jobs_for_user_now | 통계 배치(내부) | 전환·가드 제거 (1) |
| enqueue_stale_user_exercise_stats_refresh_jobs | 통계 배치(문) | 전환 제거 (1) |
| enqueue_manual_pr_stats_refresh | 트리거 | 전환 7곳 제거 (1) |
| stage_wodup_import_batch / import_wodup_batch_to_canonical / remove_import_data_v1 (remap_wodup_user_customs_to_canonical_v1은 #1175에서 드롭) | 관리자 인입 | 전환 제거 (3) |
| backfill_wodup_session_timing_v1 / backfill_wodup_bodyweight_snapshot_v1 | 백필(재인입 런북 도구) | 전환 제거 (3) |
| import_motra_workouts_v1 | 관리자 인입(문) | 전환 제거 (3) |
내부 함수의 신원 직독 — 트랙 전 38함수 → 후 0 (인자화; 부류별 Phase) 통계 갱신 체인 refresh_/enqueue_job/rollover/snapshot_window (Phase 1) · 저장/검증 엔진 save_session_v5_engine·delete_session_v5_engine·validate*(이슈 #1215 전에는 save_workout_v4_engine·update/delete_completed_session_v4_engine·save_plan_v3_engine) (Phase 2) · 인입 내부 stage/enqueue/start/import engine (Phase 3) · 읽기 코어 달력/로그테이블/PR/검색/발표 mains·온보딩·카탈로그 엔진 등 잔여 (Phase 4)
문 — auth.uid() 1회 읽기(정당): 문은 진입에서 딱 한 번 읽는다. 2회 이상 읽던 문 8곳 (모더레이션 상태·달력 범위 요약 주석·WodUp 문 주석·정합성 validator 2종의 인자 기본값)은 Phase 5에서 1회로 정리했다.
role 클레임 셋업 — 트랙 전 3함수 → 후 0 (Phase 5) cron 래퍼 2종(run_user_exercise_stats_refresh_cron·backfill_scan_cron)과 가입 트리거 (initialize_user_pr_overview_snapshot)가 request.jwt.claim.role='service_role'을 꾸미던 것을 제거 — 원인이던 워커 문 2종의 특권 판정에 session_user 인식을 넣어 pg_cron 세션 (role GUC가 'none')을 직접 통과시킨다.
4. 게이트 (Phase 5)
supabase/tests/database/identity_passing_contract.test.sql(pgTAP, migration-smoke 레인)이 상시 단언한다:
- 전 함수 신원 전환 0 — 어떤 public 함수 본문에도
set_config('request.jwt...')가 없다. - 인자 기본값 무신원 — 어떤 public 함수도 인자 DEFAULT에서
auth.uid()를 읽지 않는다. - 최대 1회 — 주석 제거 후
auth.uid()를 2회 이상 읽는 public 함수가 없다(문 1회·내부 0회의 상한). - 내부 장부 — 묶음 A·B·C의 내부(엔진·코어) 43종은 ① 존재하고 ② 본문에서 주변 신원 (auth.uid·request.jwt)을 전혀 읽지 않으며 ③ 클라이언트 롤(anon·authenticated)에서 실행 불가다.
- 친구/그룹 접근점의 세부 규칙은 friend-access.md의 스윕(friendAccessSqlContract)이 계속 잠근다.
새 함수를 추가할 때: 문이면 진입에서 한 번 읽고 게이트 → 명시 인자 전달, 내부면 인자만 (그리고 게이트의 내부 장부에 등재). 게이트가 위반을 CI에서 거부한다.