앱 계정 권한 계약 (이슈 #1258·#1259)
원칙 (오너 결정 2026-09-04, D1): 앱 계정(
anon= 로그인 안 한 방문자,authenticated= 로그인한 사용자)은 선언된 만큼만 가진다. 표 권한은 RLS 정책에서 따라오고, 함수 권한은 RPC 문(SECURITY DEFINER) 선언에서 따라온다. 새로 만드는 표·함수·시퀀스는 닫힌 채로 태어나고, 필요한 권한은 마이그레이션이 같은 파일에서 명시로 준다. 규칙은 이름 목록이 아니라 기계가 판정한다.
정본 검사 = supabase/tests/database/app_role_privilege_rules_v1.test.sql(pgTAP, 허용 목록 없음). 명시적 신원 전달(#1033)이 "문 안에서 누구 것을 다루는가"를, 이 계약은 "누가 문을 두드릴 수 있는가"를 정한다. 유저 기록 원본 불변(#1236)의 TRUNCATE 차단 트리거는 마지막 방어선이고, 이 계약은 그 앞에서 권한 자체를 없앤다.
1. 규칙
| # | 규칙 | 근거 |
|---|---|---|
| R1 | postgres 가 public 에 만드는 표·시퀀스·함수의 기본 권한에 anon·authenticated·PUBLIC 이 없다. service_role 은 종전대로 전부(RLS 를 우회하는 서버 계정, 엣지 함수·관리자 서버가 쓴다) | Supabase 기본값은 "새 객체는 앱 계정에 전부"였고, 베이스라인 v2 머리에서 끈 것을 같은 파일 꼬리의 덤프가 다시 켰다. 잊으면 조용히 열리는 구조를 뒤집어, 잊으면 실행 거부(42501)로 드러나게 한다 |
| R2 | 표: 앱 계정의 SELECT/INSERT/UPDATE/DELETE(열 단위 포함)는 그 역할·그 명령의 RLS 정책이 있을 때만. TRUNCATE·REFERENCES·TRIGGER·MAINTAIN 은 절대 없다. 권한이 있는 표는 RLS 가 켜져 있다. 시퀀스는 권한 없음. 뷰는 security_invoker 이고 SELECT 만. 정책은 있는데 권한이 없는 죽은 정책도 없다 | 권한과 정책이 따로 놀면 어느 쪽도 믿을 수 없다. TRUNCATE 는 RLS 를 우회하고, TRIGGER·REFERENCES 는 앱 계정이 가질 이유가 없다 |
| R3 | 함수: anon·PUBLIC 실행 권한 0개(proacl 이 비어 있는 것도 PUBLIC 실행 가능이므로 위반). 트리거 함수는 앱 계정 실행 불가. authenticated 실행 권한은 SECURITY DEFINER(RPC 문) 이거나 정책·제약·기본값·인덱스·뷰가 pg_depend 로 참조하는 함수에만 | 앱은 익명 RPC 를 하나도 쓰지 않는다. 도우미 함수는 문(정의자) 안에서 돌므로 호출자 권한이 필요 없고, 제약식·정책식이 부르는 함수만 호출자 권한이 필요하다 |
예외를 두고 싶으면 이 문서를 먼저 고치고 규칙(쿼리)을 바꾼다. 검사 파일에 이름을 적어 빼는 것은 금지.
2. 마이그레이션 작성 규칙
- 새 표:
create table if not exists …뒤 같은 파일에alter table … enable row level security+ 정책 + 정책과 짝이 맞는grant. 앱이 RPC 로만 쓰는 표는 정책도 권한도 주지 않는다(service_role은 기본 권한으로 이미 전부). - 새 RPC 문:
create or replace function뒤 같은 파일에revoke all on function … from public, anon, authenticated; grant execute on function … to authenticated, service_role;(관리자 문도authenticated— 안에서is_lift_guild_admin()로 가른다). - 내부 엔진·코어·트리거 함수:
grant execute … to service_role만(트리거 함수는 그것도 필요 없다). drop function뒤 다시 만드는 함수는 권한을 다시 선언한다 —create or replace는 권한을 유지하지만drop+create는 기본 권한(R1 이후 = 닫힘)으로 돌아간다.scripts/check-supabase-migrations.mjs가 같은 파일에 선언이 없으면 거부한다.revoke … from public만으로는 부족하다 — PUBLIC 과anon·authenticated는 별개 grantee 다.
3. 적용 장부 (2026-09-04 Production 실측, v0.16.0 스키마)
| 항목 | 전 | 후(마이그레이션 app_role_privileges_v1) |
|---|---|---|
| 앱 계정이 TRUNCATE·REFERENCES·TRIGGER·MAINTAIN 을 가진 표 | 33개 표 248건 | 0 |
anon 이 어떤 표 권한이든 가진 곳 | 29개 표(anon 정책 0개) | 0 |
authenticated 가 정책 없이 DML/SELECT 를 가진 곳 | 10개 표 41건 | 0 |
| 죽은 정책 | 25개(오너 결정 D2: 삭제) | 0 |
anon·PUBLIC 실행 가능 함수 | 26개 | 0 |
| 앱 계정이 부를 수 있는 트리거 함수 | 6개 | 0 |
authenticated 실행 권한이 있는 SECURITY INVOKER 도우미 | 21개 | 제약이 참조하는 2개(exercise_target_muscles_valid_v1·required_inputs_valid_v1)만 |
| 기본 권한에 앱 계정 | 표·시퀀스·함수 전부 | 없음 |
실제로 뚫려 있던 것: 앱 공개 키만으로 user_pr_event_count_v1(아무 사용자 id) 가 HTTP 200(남의 PR 달성 횟수), apply_training_attendance_policy_v1(아무 사용자 id) 가 남의 출석 투영 재계산. 둘 다 엔진이므로 service_role 전용으로 닫는다.
4. 게이트
- pgTAP
app_role_privilege_rules_v1.test.sql— CI migration-smoke·npm run ci:local(db 단계)·db:preflight에서 전수 판정. scripts/check-supabase-migrations.mjs— 새 표·drop후 재생성 함수에 같은 파일 권한 선언 요구(조기 경고).supabase/schema.sql(#1252 이후 실제 상태 덤프) —tablePrivilegesFor/functionGrants로 Docker 없이 같은 상태를 읽는다.