명시적 신원 전달 전면 적용 — 계정인 척·계정 전환 없는 데이터베이스 (2026-09-01)
- 기간: 2026-08-31 ~ 2026-09-01 (1세션 연속, 오너: "명시적 신원 전달 원칙을 앱 전체에 적용… 비용을 고려하지 말고 확실하고 세세하게" + "중간에 묻지 말고 phase 5까지 완주")
- 랜딩: PR #1035(묶음 A:
20260901800000) · PR #1037(묶음 B+C:20260901830000·840000·850000) — Productiondb push적용(§15 직렬화) - 설계서: 이슈 #1033 본문·스레드(Phase 설계 + 예상 효과)
- 정본:
docs/contracts/identity-passing.md— 세 가지 자리 규칙·적용 장부·게이트 명세 - 도구: 정본 추출-치환 빌더(Production
pg_get_functiondef28건 + 고정 기반 schema 스냅샷), Production 원자 드라이런(DO-raise) + 행위 프로브 - 게이트: pgTAP
identity_passing_contract.test.sql(신원 전환 0·기본값 무신원·auth.uid() 최대 1회·내부 장부 43종) — migration-smoke 레인 상주 - 버그리포트: 없음(결함 수리가 아닌 구조 트랙 — 발단인 친구 리포트 0kg은 #1028에서 종결)
- 계약: 신원은 문(클라이언트 호출 함수)에서
auth.uid()를 딱 한 번 읽고, 내부는 명시 인자(p_actor/p_viewer/p_user_id)만 받는다.set_config로 다른 계정·역할인 척하는 코드는 금지.
한 줄 요약 — 서버 함수 곳곳에서 "요청자를 바꿔치기(JWT 클레임 스왑)"하거나 "함수 안에서 몰래 현재 로그인을 다시 읽는" 방식으로 처리되던 신원을, 전부 "문에서 한 번 확인하고 명시 인자로 전달"로 바꿨다. 신원 전환 11함수→0, 내부 신원 직독 38함수→0, role 클레임 셋업 3함수→0. 위반은 pgTAP 게이트가 CI에서 거부한다.
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 계약 문서(docs/contracts/identity-passing.md) 정본화 | ✅ #1035 |
| Phase 1 | 통계 갱신 체인 17함수 + 트리거 전환 7곳 제거 | ✅ #1035, Production 적용 |
| Phase 2 | 저장 체인(쓰기 엔진 4·검증기 2·문 4·온보딩 3) | ✅ #1037 (20260901830000) |
| Phase 3 | 인입 스위트(WodUp 문 4·엔진 2·remap/백필 스왑 제거·검증 엔진 신설) | ✅ #1037 (20260901840000) |
| Phase 4 | 읽기 코어(PR 상세·로그 테이블·달력·검색·발표 mains·세트 지표) | ✅ #1037 (20260901850000) |
| Phase 5 | 잔재 청소(cron role 셋업·2회 읽기·인자 기본값) + 상시 게이트 | ✅ #1037 (20260901850000) |
1. 배경
친구 리포트 월별 총합 0kg(#1028)을 수리하면서, 친구 데이터를 "대상 유저로 로그인한 척 바꿔서 기존 함수를 부르는" 방식이 드러났다. 오너 결정으로 #1028은 전환 없는 전용 접근점으로 재설계했고, 같은 원리(계정인 척, 역할인 척)가 앱의 다른 곳에도 있는지 조사한 결과 통계 배치·관리자 인입·백필·cron 등 11개 함수가 같은 패턴을 쓰고 있었다. 오너가 이 원칙의 전면 적용을 지시했다(#1033).
2. 문제 제기
- 계정 바꿔치기: 통계 재계산·인입 도구가
set_config('request.jwt.claim.sub', …)로 요청자를 대상 유저로 바꿔치기한 뒤 기존 함수를 호출했다. 복원을 잊으면 이후 코드가 남의 계정으로 돌고, 예외 경로에서 누가 누구인지 보증이 없다. - 역할인 척: cron 래퍼·가입 트리거가
role='service_role'을 꾸며 하위 게이트를 통과했다 — 게이트가 pg_cron 세션(role 값이 'none')을 인식하지 못한 것을 셋업으로 달랜 것. - 몰래 다시 읽기: 내부 계산 함수 38곳이 자기 안에서
auth.uid()를 다시 읽었다. definer 체인(그룹 보드 등)에서는 이 값이 호출 문맥에 따라 달라져, 예를 들어 세션 상세의 무게 지표가 누가 만들었는지에 따라 빠지는 실제 이상이 있었다.
3. 해결 방안
원칙(오너 D1, #1033): 신원은 문에서 딱 한 번. 클라이언트가 부르는 문(door)만 auth.uid()를 읽고 권한을 검사한 뒤, 내부(엔진·코어)는 "누구의 데이터를, 누가 보는가"를 전부 명시 인자로 받는다. 트리거·배치는 행 데이터·작업 큐의 user_id를 그대로 전달한다.
| 대안 | 판정 |
|---|---|
| 전환 코드에 복원 가드만 보강 | 기각 — 패턴 자체가 위험(예외 경로 보증 불가), 오너가 구조 제거를 선택 |
| 함수마다 개별 수리 | 기각 — "비용 고려하지 말고 전면 적용" 지시, 부류별 일괄 재발행이 누락을 막는다 |
| 부류별 Phase(통계→저장→인입→읽기→청소) + 상시 게이트 | 채택 |
4. 적용한 내용
- Phase 1 통계 갱신 체인: 내부 12함수의 전환·주변 읽기 제거, 문 4곳 1회 읽기 정규화, 트리거의 전환 7곳 제거.
- Phase 2 저장 체인: 쓰기 엔진 4종·검증기 2종에 p_actor 선행 인자(서명 변경은 drop 후 재생성 — #938 오버로드 함정), 문은 관통만. 온보딩 v2→v3→v4 체인 p_user_id 관통.
- Phase 3 인입 스위트: WodUp 문 4종은 "특권 호출만 p_user_id 채택, 아니면 본인" 1회 해석으로 정규화. remap·백필·remove_import·motra의 전환 스캐폴딩 전면 제거. 내부 루프가 문을 다시 부르다 게이트에 걸리는 문제는 검증 엔진 신설(validate_user_exercise_stats_integrity_engine_v1)로 구조 해소 — 내부는 내부를 부른다.
- Phase 4 읽기 코어: PR 상세 계열 내부 9종+문 4종, 로그 테이블, 계획 상세, 세션 검색, 발표 mains·세트 지표 맵(+p_viewer). 세션 상세 빌더는 지표 뷰어를 세션 소유자로 고정 — 그룹 보드 등에서 지표가 호출 문맥에 좌우되던 이상의 구조적 해소.
- Phase 5 잔재 청소: 워커 문 2종의 특권 판정에 session_user 인식을 넣어 pg_cron을 직접 통과시키고, cron 래퍼 2종·가입 트리거의 role 셋업 제거. 2회 읽기 문 8곳 정리, validator 2종의 인자 기본값
auth.uid()제거. 상시 게이트 pgTAP 신설.
작업 중 드러난 것:
- DB 기본권한(default privileges)이 재생성 함수에 service_role 실행권을 자동 부여 — drop 후 재생성한 내부는 명시 revoke 없이는 위생 핀(
internal_surface_grant_hygiene)을 위반한다. 실측으로 확정하고 내부 전부에 revoke를 명시했다. - 같은 함수 다중 트랙 재발행 되덮기가 실제로 한 번 발생 — Phase 5가 Production(묶음 B 적용 전) 본문을 기반으로 워커 문을 재발행해 묶음 B의 엔진 재조준을 되돌릴 뻔했고, 로컬 계약 테스트가 적발했다. 빌더 기반을 묶음 B 산출물로 교체해 해소.
- pg_cron 게이트 미인식의 원인: 특권 판정 coalesce 체인이 role GUC 'none'에서 멈춰 session_user까지 내려가지 않았다 — 셋업(꾸미기)이 아니라 판정(인식)을 고치는 것이 맞는 방향.
5. 적용 결과
| 항목 | 전 | 후 |
|---|---|---|
신원 전환(set_config 계정 스왑) 함수 | 11 | 0 |
| 내부 함수의 신원 직독(auth.uid) | 38 | 0 |
| role 클레임 셋업 함수 | 3 | 0 |
| 인자 기본값의 auth.uid() | 2 | 0 |
| auth.uid() 2회 이상 읽는 함수 | 8 | 0 |
| 위반 감시 | 없음 | pgTAP 게이트 상주(6단언) |
검증: Production 원자 드라이런 + 행위 프로브(저장 문 관통 22023/42501, 무클레임 엔진 호출, cron 셋업 제거 후 통과, 실제 유저 클레임으로 달력·검색·모더레이션) 전부 OK. 로컬 check 2,271/2,271. CI(verify+migration-smoke) 초록. 시각·감각 품질에는 영향이 없는 서버 구조 트랙이라 화면 확인 대상 없음.
6. 이번 개선으로 향상된 것
- 계정 간 격리 보증: "누가(행위자)·누구의 데이터(대상)·누가 보는가(뷰어)"가 모든 내부 함수의 서명에 드러난다. 예외 경로에서 남의 계정으로 남는 종류의 사고가 구조적으로 불가능해졌다.
- definer 체인의 결정성: 그룹 보드 완료 반영처럼 다른 사람 문맥에서 도는 저장·조회도 항상 소유자 기준으로 계산된다.
- 구조적으로 남는 것: identity-passing.md 계약 + pgTAP 상시 게이트(새 함수의 위반을 CI가 거부) + "내부는 내부를 부른다" 원칙(검증 엔진 분리 패턴).
남은 것
- 없음 — Phase 0~5 전부 랜딩·Production 적용·게이트 상주. 관리자 "유저로 보기"(앱 계층 임퍼서네이션)는 계약 §2대로 의도된 예외로 유지.