# task-2990 — InsuRo 개인정보 처리위탁 동의 게이트 집행 (A: DDL 불요분)

- **팀**: dev3-team (다그다) · **레벨**: Lv.2 · **검증 레벨**: security
- **PR**: [#239](https://github.com/Jeon-Jonghyuk/InsuRo/pull/239) **OPEN · MERGEABLE** — **머지는 ANU. 직접 머지하지 않았음**
- **브랜치**: `task/task-2990-dev3` · **워크트리**: `/home/jay/projects/InsuRo/.worktrees/task-2990-dev3`
- **base**: `deeaf846fc313abb7b6068a824a78eba768efeba` · **head**: `270cbc996f3cdb7c7f1f1bb7694b04515b188636`
- **CI**: **11 / 11 GREEN** (`e2e-test` 포함)

---

## S — 상황

task-2983 이 개인정보처리방침을 게시하고 동의 체크박스를 넣었으나, **동의 여부를 판정할 서버 측 상태가 없어** 게이트가 집행되지 않았다.

착수 전 ANU 실측 근거 7건을 전건 재확인했다 (줄 번호는 다르나 심볼 일치).

| 증거 | ANU 지목 | 실측 결과 |
|---|---|---|
| 1 | 동의가 로컬 state 로만 존재 | 확인 — `AuthForm.tsx:37 consentChecked` |
| 2 | 인증 호출에 동의 미전달 | 확인 — `signUp` 57/63행, `signInWithOAuth` 93행 |
| 3 | 체크박스 강제가 회원가입 모드에만 | 확인 — 49·86·145·260행 전부 `mode === "signup"` 조건부 |
| 4 | 게이트가 세션만 검사 | 확인 — `AuthGuard.tsx:41,47 setAuthenticated(!!session)` |
| 5 | 콘솔 OAuth 직접 호출로 우회 성립 | 확인 — 재현 테스트로 실증 (아래 RED) |
| 6 | `App.tsx` 수정 불요 | **부분 반증** — 라우트에는 맞으나 형제 컴포넌트 누락 (아래 잔여 위험) |
| 7 | `customer_consents` 테이블 실재 | 확인 — 단 **재사용 불가**로 판정 (아래 근거) |

## C — 복잡성

**증거 6 이 완전하지 않았다.** `AuthGuard` 는 `<Routes>` 만 감싸며, `ChatNotificationListener` 는 `App.tsx:52` 에서 **형제(sibling)** 로 마운트되어 세션만으로 동작한다. 즉 `AuthGuard` 만 고쳐서는 "동의 없이 서비스 이용 불가"가 완전히 성립하지 않는다. 수정 지점이 모두 금지 경로라 후속 태스크로 넘긴다.

또한 `src/components/AuthGuard.test.tsx:227` 이 "인증된 세션 → children 렌더"를 단언하는데, 동의 게이트를 넣으면 **반드시 깨진다**. 이 파일은 명세 산문의 "관련 테스트"에는 해당하나 `allowed_resources.paths` glob(`src/components/__tests__/**`)에는 매칭되지 않는다.

## Q — 질문

동의 상태를 **DDL 없이** 어디에 둘 것인가?

## A — 답변

### `customer_consents` 재사용 불가 판정 (명세 1순위 요구)

`supabase/migrations/20260309_phase_j_consent.sql` 실측:

- `customer_id UUID NOT NULL REFERENCES public.customers(id)` — 모든 행이 **실재하는 CRM 고객 레코드**를 요구한다.
- `agent_id` 는 "고객의 동의를 **대신 기록한** 설계사"를 가리킨다. 이 테이블은 `src/components/crm/ConsentTab.tsx` 가 읽고 쓰는 **CRM 자산**이며, 로그인 사용자(설계사) 본인의 서비스 이용 동의를 담는 자리가 아니다.
- 재사용하려면 설계사마다 가짜 `customers` 행을 만들어야 하고(FK 제약), CRM 동의 데이터가 오염된다.

→ **명세 2순위인 auth user metadata 채택.** 근거는 `AuthGuard.tsx:26` 주석에도 박제했다.

### 구현

1. `AuthGuard` 가 **세션 + 동의** 를 함께 검사. 렌더 분기: 공개라우트 → loading → 세션없음(Navigate) → **동의없음(ConsentGate)** → children
2. 동의 미완료 시 children 을 **완전 대체**한다. 오버레이가 아니다 — 오버레이면 보호 페이지가 마운트되어 데이터를 조회해 게이트가 장식이 된다
3. `user_metadata.privacy_consent = { version, consented_at, method }` 로 **동의 시점 기록**(명세 요구 4)
4. `hasValidPrivacyConsent()` fail-closed — null/undefined/문자열/배열/버전불일치/`consented_at` 공백·파싱불가/예외 전부 `false`
5. 이메일 회원가입은 `signUp({ options: { data } })` 로 동의 동반 전송 → 신규 가입자 재질문 없음
6. **로그인 모드 게이트**(명세 요구 3)는 `AuthGuard` 가 담당 — 기존 사용자는 최초 1회만 요구, 이후 재질문 없음
7. 동의 거부자를 위한 로그아웃 탈출 경로 (실패 시 에러 노출)

### ★ 한계 — 보안 경계가 아니다

`user_metadata` 는 클라이언트가 `supabase.auth.updateUser()` 로 수정할 수 있다. **이 작업으로 보안 경계가 세워지지 않았다.** 목적은 **동의 절차의 집행**이지 위변조 방지가 아니다. 정식 영속화는 **후속 DDL 배치(B)** 에서 DB 컬럼 + RLS 로 이전한다. 한계는 `AuthGuard.tsx:11-25` 와 `AuthForm.tsx` signUp 호출부 주석에 명시했다.

---

## 수정 파일별 검증 상태

| 파일 | 변경 | 검증 방법 | status |
|---|---|---|---|
| /home/jay/projects/InsuRo/.worktrees/task-2990-dev3/src/components/AuthGuard.tsx | 세션+동의 이중 검사 · ConsentGate · fail-closed 파서 | vitest 32 PASS · tsc 0 · 실브라우저 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2990-dev3/src/components/AuthForm.tsx | signUp options.data 동의 배선 | vitest 6 PASS · grep 73행 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2990-dev3/src/components/__tests__/AuthGuard.consent.test.tsx | 우회 재현 + fail-closed 16케이스 | RED 5 failed → GREEN 16 PASS | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2990-dev3/src/components/AuthGuard.test.tsx | 기존 1건에 동의 metadata 추가 | 16/16 PASS (나머지 15건 무손) | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2990-dev3/e2e/task-2990-consent-gate.e2e.mjs | 실브라우저 시나리오 2·3 | 실행 · 스크린샷 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2990-dev3/e2e/task-2990-signup-gate.e2e.mjs | 실브라우저 시나리오 1 | 실행 · 스크린샷 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2990-dev3/tests/e2e/composite-design-vpath.spec.ts | 픽스처 세션에 privacy_consent 부여 (3곳) | playwright PASS · CI green | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2990-dev3/tests/e2e/crm-customers-vpath.spec.ts | 픽스처 세션에 privacy_consent 부여 (3곳) | playwright PASS · CI green | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2990-dev3/tests/e2e/keyword-analysis-info-generate.spec.ts | 픽스처 세션에 privacy_consent 부여 (3곳) | playwright PASS · CI green | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2990-dev3/tests/e2e/new-design-comparison-honest-disclosure.spec.ts | 픽스처 세션에 privacy_consent 부여 (3곳) | playwright PASS · CI green | verified |

## 테스트 결과

| 항목 | 결과 |
|---|---|
| 우회 재현 (RED, 커밋 2408c83) | **5 failed / 6 passed** — 마아트가 별도 worktree 에서 독립 재현 |
| 우회 재현 (GREEN, HEAD) | **16 / 16 PASS** |
| 전체 회귀 | **1248 passed / 87 files · 0 failed** |
| 기준선 | 명세는 1226 이나 실측 `deeaf84` = **1232**. 마아트가 origin/main 별도 worktree 에서 1232 재현. 1232 + 신규 16 = 1248 정확히 일치 |
| npm run build | **EXIT=0** |
| tsc --noEmit | **0 errors** |
| CI e2e-test (playwright) | **success** — 단, 최초 head 에서 **10건 실패 → 수정 후 해소** (아래 이슈 7) |
| CI 전체 | **11 / 11 GREEN** |

**기준선 불일치 근거**: 명세의 1226 은 task-2984(/privacy PWA 배너) 이전 수치다. 해당 태스크가 1226→1232 로 올렸고 `deeaf84` 에는 반영되어 있다. 회귀 아님.

### ★ 정정 — "회귀 0" 최초 판정은 불완전했다

`npx vitest run` 만으로 회귀를 판정했으나, `vitest.config.ts` 의 `exclude` 가 **`tests/e2e/**` 를 제외**한다.
즉 vitest 1248 PASS 는 **Playwright E2E 를 전혀 포함하지 않은 수치**였다. CI 가 실제 회귀를 잡아냈다.
**pytest/vitest PASS ≠ 회귀 없음** 을 실증한 사례로 남긴다.

## L1 스모크테스트

- **서버 재시작**: 해당없음 — 프론트 전용 변경. 라이브 서비스(`insuro-api.service`) 미접촉. 격리 포트 4319 에 `vite preview` 로 **빌드 산출물(dist)** 을 띄워 검증 후 종료
- **API 응답 확인**: 해당없음 (서버 코드 무변경)
- **스크린샷**: `/home/jay/workspace/memory/reports/task-2990-shots/` — **실제 브라우저 렌더, 픽스처 아님**

| 시나리오 | 결과 | 증거 |
|---|---|---|
| 1. 신규가입 동의 미체크 → 진행 불가 | Google CTA disabled=true, 체크 시 false / 이메일 제출 disabled=true, 체크 시 false — **양 경로 모두 차단** | 1a-signup-google-disabled.png · 1b-signup-google-enabled.png · 1c-signup-email-disabled.png |
| 2. 동의 없는 세션 → 본 화면 차단 | 동의 게이트만 렌더, 앱 콘텐츠 **전무**. 팀장이 이미지를 직접 열어 로그인월이 아님을 확인 | 2-session-without-consent-BLOCKED.png |
| 3. 동의 완료 사용자 → 재질문 없음 | 대시보드 정상 렌더, 게이트 노출 0건 — **기존 사용자 무회귀** | 3-consented-user-NOT-REASKED.png |

시나리오 2·3 의 세션은 `sb-<ref>-auth-token` **localStorage 주입**이다(`SESSION-INJECTED-NOT-A-REAL-JWT` 로 라벨링). **서명 JWT 위조가 아니며 렌더 경로만 검증**한다.

## 발견 이슈 및 해결

| # | 심각도 | 발견자 | 내용 | 조치 |
|---|---|---|---|---|
| 1 | MEDIUM | 마아트·로키 **독립 중복** | `consented_at: "   "` 등 파싱 불가 문자열이 유효 동의로 통과 (`.length === 0` 만 검사) | **해결** — `trim()` + `Date.parse()` 검증 추가, 회귀 테스트 3건 추가 |
| 2 | MEDIUM | 로키 | `signOut()` 실패를 무시 → 동의 거부자가 조용히 갇힘. 자유로운 의사에 의한 동의 요건 훼손 | **해결** — `{ error }` 확인 + 전용 에러 메시지 + 재시도, 테스트 2건 추가 |
| 3 | MEDIUM | 마아트 | `customer_consents` 재사용 검토 근거가 코드에 없음 | **해결** — `AuthGuard.tsx:26` 주석 + 본 보고서에 판정 근거 기재 |
| 4 | HIGH 등급 지적 1건 | 로키 | `ChatNotificationListener` 가 `AuthGuard` 형제로 마운트되어 동의 게이트 중에도 고객명+메시지 토스트 노출 가능 | **미해결 — 범위 밖** (아래) |
| 5 | LOW | 로키 | `isPublicRoute` 가 브라우저 URL 정규화에 의존 (세그먼트 미인식 `startsWith`) | **미해결 — 범위 밖**. 로키가 `/chat/../dashboard`, `//chat/` 실측했으나 브라우저가 선정규화해 **실제 우회 재현 안 됨** |
| 6 | 절차 | 마아트 | PR·보고서 부재, 타이머 미종료 | **해소** — 마아트 검증이 종결 절차보다 앞서 실행된 시점 차이. 본 보고서·PR #239 로 이행 |
| 7 | **회귀** | CI | **E2E 10건 실패** — 4개 spec 이 픽스처 세션을 `user_metadata: {}` 로 만들어, 새 게이트가 보호 라우트 대신 동의 화면을 렌더 | **해결** — 아래 |

### 이슈 7 상세 — 실제 회귀였다 (pre-existing 아님)

**판정 근거**: base `deeaf84` 의 `e2e-test` = **success**, 최초 head `91497fd` = **failure**. 실측 대조로 확정했다.
"pre-existing 이겠지"라고 넘기지 않고 base 체크런을 직접 조회해 가렸다.

**원인**: `tests/e2e/` 의 4개 spec(`composite-design-vpath`, `crm-customers-vpath`, `keyword-analysis-info-generate`, `new-design-comparison-honest-disclosure`)이
픽스처 세션을 `user_metadata: {}` 로 생성한다. `AuthGuard` 가 동의를 요구하게 되면서 보호 라우트 대신
동의 게이트가 렌더되어 `#existing-analysis-input` 등 기대 요소가 나타나지 않았다.

**조치**: 각 spec 의 픽스처를 **"이미 동의를 마친 기존 설계사"** 로 표현하도록 `privacy_consent` 를 부여했다.
JWT payload · 세션 `user.user_metadata` · `/auth/v1/*` 목 응답 **3곳 전부**에 동일 레코드를 넣었다
(불일치 시 `onAuthStateChange` 가 빈 metadata 로 덮어써 게이트가 재노출되기 때문). 파일당 3곳 × 4파일 = 12곳.
**단언(assertion)은 일절 약화하지 않았고**, 게이트를 우회하려 `AuthGuard` 를 고치지도 않았다.

**결과**: 로컬 playwright 에서 문제의 10건 전부 통과, 재푸시 후 **CI `e2e-test` success · 11/11 GREEN**.

### ⚠ 잔여 위험 — 후속 태스크 필요 (이슈 4)

`App.tsx:52` 에서 `ChatNotificationListener` 가 `<AuthGuard>` 의 **형제**로 마운트된다. 이 컴포넌트는 자체적으로 `getSession()`/`onAuthStateChange()` 를 구독해 **세션만 있으면(동의 무관)** `conversation_messages` INSERT 를 realtime 수신하고, 고객명 + 메시지 60자를 토스트로 띄운다.

→ 동의 게이트가 떠 있는 동안에도 **고객 PII 가 노출될 수 있다.** ConsentGate 문구 "동의하지 않으면 서비스를 이용하실 수 없습니다"가 현재는 완전히 사실이 아니다.

수정 지점 `src/App.tsx`(명세 **수정 금지**)·`src/components/ChatNotificationListener.tsx`(allowed_resources 밖) 가 모두 범위 밖이라 손대지 않았다. **ANU 판단 필요.**

부수 관찰(로키, **미확인**): 이 리스너는 `filter` 없이 전체 `conversation_messages` INSERT 를 구독한 뒤 클라이언트에서 `agent_id === userId` 로 거른다. Realtime RLS 가 약하면 타 상담원 메시지 payload 가 브라우저 메모리까지 도달할 수 있다. 본 태스크에서 검증하지 않았다. **별도 감사 권고.**

## ★ ANU 판단 요청 — capability glob 갭

`src/components/AuthGuard.test.tsx` 를 수정했다. 이 파일은
- 명세 산문 "수정 허용: AuthGuard.tsx · AuthForm.tsx · **관련 테스트**" 에는 해당하나
- `allowed_resources.paths` YAML glob(`src/components/__tests__/**`)에는 **매칭되지 않는다**

`forbidden_paths` 침범은 아니다(trip-wire 무위반). 그러나 `task-scope-guard.sh` 는 allowlist 매칭이므로 **머지 시점에 차단될 수 있다.**

**불가피성**: `AuthGuard` 가 세션-only → 세션+동의 로 바뀐 이상, 유효 동의 없는 fake session 으로는 "인증된 사용자는 children 을 본다"는 기존 계약을 재현할 수 없다. 마아트 독립 판정도 **"정당함 (구현상 불가피)"** 이며, 16건 중 **1건만** 변경(assertion 로직 무손, `user_metadata` 필드 추가만)되고 나머지 15건은 그대로다.

봇이 스스로 scope 를 확대할 수 없으므로 **ANU 승인 또는 glob 확대(`src/components/**`)를 요청**한다.

## 독립 검증 / 보안 감사 (security 레벨)

- **마아트 독립 검증**: **NEEDS WORK** → 지적 1·3·6 해소 후 재검증 대상. 팀 주장 6건 중 5건 **확인됨**, 1건(fail-closed) **부분 반증** → 수정 완료. RED/회귀/빌드를 별도 worktree 에서 전건 독립 재현
- **로키 보안 감사**: **NEEDS WORK** → 지적 1·2 해소. 이슈 4·5 는 범위 밖으로 이관. 프로토타입 오염·Symbol.toPrimitive·throwing getter·`Object.create(null)` 반례 전건 차단 확인(크래시/DoS 경로 없음). e2e 스크립트에 실자격증명·실 PII·서명 JWT **없음** 확인. `console.log`/`console.error` 유출 **0건**

## trip-wire 5종 실측

| 항목 | 실측 |
|---|---|
| Critical7 | 0 |
| PII net-new | 0 (로키 확인 — e2e 픽스처는 `e2e@example.test`) |
| 회귀 실패 | 0 — 단위 1248 passed / 0 failed **+ CI e2e-test success**. 중간에 E2E 10건 회귀가 발생했으나 이슈 7 로 해소, 최종 CI 11/11 GREEN |
| forbidden_paths 침범 | 0 (`git diff --name-only origin/main...HEAD` 10파일, 금지 경로 전부 부재) |
| nonce = task_id | task-2990 일치 |

## 모델 사용 기록

| 팀원 | 역할 | 모델 | 비고 |
|---|---|---|---|
| 아네 (Aine) | UX/UI 설계 | sonnet | 동의 인터스티셜 카피·레이아웃·접근성 |
| 모리건 (Morrigan) | 테스트/QA | sonnet | RED 재현 테스트 · 실브라우저 E2E |
| 루 (Lugh) | 백엔드/로직 | sonnet | AuthGuard 게이트 · 감사 지적 수정 |
| 브리짓 (Brigid) | 프론트엔드 | sonnet | AuthForm signUp 배선 |
| 마아트 (Ma'at) | 독립 검증 | sonnet | 횡단조직 |
| 로키 (Loki) | 레드팀 보안 | sonnet | 횡단조직 |

haiku 미사용. 팀장(Opus)은 직접 코딩하지 않았고 설계·분배·검토·통합만 수행했다.

## 3 Step Why

- **1st Why — 왜 동의 없이 진입되었나?** `AuthGuard` 가 `!!session` 만 봤다. 동의는 `AuthForm` 로컬 state 로만 존재해 화면을 벗어나면 소멸했다.
- **2nd Why — 왜 동의가 세션에 붙지 않았나?** 동의를 담을 영속 상태가 설계되지 않았다. `customer_consents` 는 CRM 고객용이라 재사용 불가였고, 사용자 본인 동의를 담을 자리가 없었다.
- **3rd Why — 왜 그 자리가 없었나?** task-2983 이 **고지(방침 게시 + 체크박스)** 까지만 범위로 잡고 **집행(게이트)** 을 다음 단계로 미뤘다. 본 태스크가 DDL 없이 가능한 집행분(A)을 채우고, 위변조 불가능한 영속화는 B 배치로 남긴다.

## 머지 판단

- **머지 필요**: Yes — 단 **ANU 판단**. 명세대로 직접 머지하지 않았다
- **브랜치**: `task/task-2990-dev3` · **PR**: #239 (OPEN, MERGEABLE)
- **머지 의견**: **CI 11/11 GREEN**, 빌드 0, RED→GREEN 실증, 실브라우저 3종 확보, E2E 회귀 해소. 다만 **머지 전 ANU 결정 2건** 필요 —
  1. `AuthGuard.test.tsx` capability glob 갭 승인 여부 (scope-guard 차단 가능)
  2. 잔여 위험(이슈 4) 을 후속 태스크로 분리할지, 본 PR 보류 후 함께 처리할지

## 세션 통계
- 총 도구 호출: 0회

