# 작업 보고: task-3042 — 조직관리 사용자 배정이 조용히 실패하는 문제

- 팀: dev3-team (다그다 / Dagda)
- 레벨: Lv.2 · 저장소: `Jeon-Jonghyuk/InsuRo` · base `2c5c67b` · 브랜치 `task/task-3042-dev3`
- **PR: #264 (draft, OPEN)** — https://github.com/Jeon-Jonghyuk/InsuRo/pull/264
- ★ **머지 금지 — ANU 가 머지한다. DDL 적용도 ANU 가 한다.**

**S**: 회장이 "인슈로 관리자 조직관리 메뉴에서 사용자배정을 해도 적용이 안 된다"고 제보했다.
**C**: `profiles` 에만 관리자 UPDATE 정책이 없어 RLS 가 거부하는데, PostgREST 는 이를 에러가 아닌 **0행**으로 처리한다. `error === null` 이라 화면은 "배정 완료" 성공 토스트를 띄웠다. 아무것도 안 바뀌었는데 성공이라고 말하고 있었다.
**Q**: 정책을 추가해 증상을 없애고, 같은 함정이 다른 곳에서 재발하지 않게 조용한 실패 자체를 구조적으로 제거할 수 있는가?
**A**: 정책 SQL 을 트랜잭션 시험(적용→검증→ROLLBACK)으로 검증해 제출했고, 조직관리 화면의 쓰기 10곳 전부가 반영 행 수를 확인해 0행이면 실패로 표시하도록 바꿨다. 성공 토스트는 더 이상 기본값이 아니다.

## S — 상황

`AdminOrganizations.tsx` 에서 브라우저가 Supabase 테이블을 직접 쓰는 지점이 10곳이다.
`profiles` 의 UPDATE 정책은 `Users can update own profile` (`USING auth.uid() = id`) 하나뿐이고,
`organizations` / `user_subscriptions` / `organization_subscriptions` 에 모두 있는
`has_role(auth.uid(), 'system_admin'::app_role)` 관리자 정책이 **`profiles` 에만 빠져 있었다.**

★ 같은 유형 사고 선례: task-3006 — 확장프로그램이 전송 실패를 "저장됐어요"로 표시해 4주간 아무도 몰랐다.

## C — 복잡성 / 실측으로 드러난 것

### 1. 0행 조용한 실패 재현 — 실측 성공 (추정 아님)

프로덕션 DB 직접 접속(`psycopg2` + `aws-1-ap-northeast-2.pooler.supabase.com:5432`),
`SET LOCAL role authenticated` + `SET LOCAL request.jwt.claims` 로 RLS 컨텍스트 재현.
전 구간 `BEGIN … ROLLBACK`. 검증 로그(비밀번호 없음): `/tmp/task3042-db-verify.log`

| 시나리오 | 결과 |
|---|---|
| system_admin 이 **남의** `organization_id` UPDATE (정책 추가 **전**) | **error=NONE, rowcount = 0** |
| 대조군: `role postgres`(RLS 우회) 동일 UPDATE | **rowcount = 1** |

→ 에러가 없는데 0행. 행은 실재하고 타겟팅 가능하다. 원인은 컬럼 오타 등이 아니라 **정책 부재**로 확정.

### 2. ★ 명세 전제 반증 — 컬럼 범위 제한은 이 스키마에서 불가능하다

명세는 "관리자가 바꿀 수 있어야 하는 컬럼 범위를 검토하라. 컬럼 제한이 가능한지 확인하라"고 했다.
검토 결과 **컬럼 제한은 실현 불가**이며, 근거는 추정이 아니라 트랜잭션 실험이다.

- `information_schema.table_privileges` 실측: `authenticated` 는 `profiles` 에 **테이블 전체 UPDATE grant** 를 이미 갖고 있다(Supabase 표준 스키마 grant). RLS 가 유일한 실질 관문이다.
- 실험 1 (grant 만): `GRANT UPDATE(organization_id)` 만 추가 → **아무 제한 효과 없음**(기존 테이블 전체 grant 가 여전히 덮음).
- 실험 2 (정정판 — REVOKE 선행): `REVOKE UPDATE ON profiles FROM authenticated` 후 `GRANT UPDATE(organization_id)` →
  일반 사용자가 **자기 자신의** `display_name` 수정 시 `permission denied for table profiles` 로 **깨짐**. `organization_id` 만 통과.
- 결론: 컬럼 GRANT 는 **정책 단위가 아니라 role 단위**다. `Users can update own profile` 정책과 새 관리자 정책이 **같은 `authenticated` role** 로 실행되므로, 컬럼을 좁히면 전 사용자의 자기 프로필 편집(display_name·phone_number·introduction·profile_photo_url·nickname 등)이 함께 깨진다.
- UPDATE 정책의 `WITH CHECK` 는 `OLD` 를 참조할 수 없어 "다른 컬럼 불변" 불변식도 표현 불가.

→ **판단**: 정책은 전 컬럼 UPDATE 를 허용하되(차선책), 실제 컬럼 범위는 애플리케이션 계층(`AdminOrganizations.tsx` 가 `{organization_id}` 만 전송)에 위임한다. 근거를 마이그레이션 파일 주석에 박제했다. 없는 기능을 있는 것처럼 쓰지 않았다.

### 3. ★ scope-guard FAIL 2건 — 도구 결함으로 판별. **자가해소하지 않음**

```
VIOLATION: src/pages/__tests__/AdminOrganizations.assertAffected.test.ts: paths 미포함
VIOLATION: src/pages/__tests__/AdminOrganizations.silentFailure.test.tsx: paths 미포함
```
그런데 명세 `allowed_resources.paths` 에는 `src/**/__tests__/**` 가 **분명히 있다.**

원인을 코드로 특정했다 — `scripts/task-scope-guard.sh` 의 `glob_match()`:
```python
if pattern.endswith("/**"):
    prefix = pattern[:-3]                      # "src/**/__tests__"
    return path == prefix or path.startswith(prefix + "/")   # 리터럴 비교
```
`/**` 로 끝나면 앞부분을 **리터럴 문자열로** 비교한다. 실제 경로에 `**` 라는 문자가 있을 리 없으므로 **영원히 미매칭**이다.

대조 실험(같은 함수 재현):

| 패턴 | 매칭 |
|---|---|
| `src/**/__tests__/**` (중간 `**`) | **False** |
| `supabase/migrations/**` | True |
| `src/pages/AdminOrganizations.tsx` | True |
| `src/pages/__tests__/**` (중간 `**` 제거한 동등 패턴) | **True** |

같은 종류인데 중간 `**` 가 있는 것만 미매칭 = 도구 결함의 서명이다. **실제 범위 이탈이 아니다.**
가드 수정도, capability 스냅샷 수정도 하지 않았다(`memory/capabilities/**` 는 forbidden_paths).
→ **ANU 판단 항목**.

### 4. ★ 봉인에 실제 구멍이 있었다 — 팀장 독립 변이로 발견 후 보강

1차 봉인은 헬퍼(`assertAffected`) 단위 테스트 + 호출부 렌더 테스트 + 구조 불변식이었다.
팀장이 clean 체크아웃(`/tmp/insuro-seal-3042`)에서 독립 변이를 돌려 **구멍 1개를 찾았다.**

| 변이 | 내용 | 1차 봉인 | 보강 후 |
|---|---|---|---|
| M1 | `handleAssignUser` 의 `assertAffected(...)` 호출 삭제 | 4 FAIL ✅ | 4 FAIL ✅ |
| M2 | `.select()` 제거 + `if (error) throw` 원시구조 복원 | 4 FAIL ✅ | ✅ |
| M3 | 렌더테스트 미커버 지점(`handleRemoveUser`) 호출 삭제 | 3 FAIL ✅ | ✅ |
| M4 | `handleAssignUser` `min: 1` → `min: 0` | 1 FAIL ✅ | 4 FAIL ✅ |
| **M5** | **`handleRemoveUser` `min: 1` → `min: 0`** | **8 passed ❌ 구멍** | **3 FAIL ✅** |
| M6 | `handleRemoveUser` `min: 1` → `min: 2` (임의값 변질) | — | **2 FAIL ✅** |

M5 는 가장 위험한 변이다 — 호출을 남긴 채 `min` 값만 바꾸면 0행이 무조건 통과해 **조용한 실패가 그대로 부활**한다.
보강으로 쓰기 지점별 `min` 계약 불변식(고정 `min:1` 6곳 / 조건부 `hadActiveSub ? 1 : 0` 4곳 / `min: 0` 리터럴 금지)을 추가해 막았다.

★ 모든 변이는 **적용 후 grep 으로 no-op 이 아님을 숫자로 먼저 증명**했고, 매회 원복 후 `git diff --stat` 빈 출력을 확인했다.

## Q — 무엇을 해야 했나

① `profiles` 관리자 UPDATE 정책 추가(근인) ② 조용한 실패 제거(근본) ③ 서버 이관은 설계 제안만.

## A — 한 일

### ① 관리자 정책 SQL (작성만 — **적용은 ANU**)

기존 패턴 그대로 재사용. 새 판별 체계 신설 없음. 기존 3개 정책 **무손상**(permissive 정책이라 OR 로 합쳐짐).
```sql
CREATE POLICY "System admins can update any profile"
  ON public.profiles FOR UPDATE TO authenticated
  USING (public.has_role(auth.uid(), 'system_admin'::app_role))
  WITH CHECK (public.has_role(auth.uid(), 'system_admin'::app_role));
```

**트랜잭션 시험 (적용→검증→ROLLBACK) — 양방향 실측**

| # | 시나리오 | 기대 | 실측 |
|---|---|---|---|
| C-1 | 관리자가 **남의** `organization_id` 변경 | 1 | **1** |
| C-2 | 일반 사용자가 **남의** `organization_id` 변경 | 0 | **0** (여전히 차단) |
| C-3 | 일반 사용자가 **자기** 프로필 변경 | 1 | **1** (기존 정책 보존) |

ROLLBACK 후 `pg_policies` 재조회 → `profiles` 정책 **3건 그대로**. **프로덕션 무변경 확인.**

### ② 조용한 실패 제거 — 쓰기 10곳 전부

`.select()` 로 반영 행을 받아 검증하는 헬퍼 하나로 통일(복붙 없음). 0행이면 `throw` → catch 에서 실패 토스트.
성공 토스트는 반영이 확인된 뒤에만 뜬다.

| # | 위치 | 연산 | `min` 계약 |
|---|---|---|---|
| 1 | `handleCreateOrg` | `organizations.insert` | 1 |
| 2 | `handleDeleteOrg` | `organizations.delete` | 1 |
| 3 | `handleAssignUser` | `profiles.update` (배정) | 1 |
| 4 | `handleRemoveUser` | `profiles.update` (해제) | 1 |
| 5 | `handleSaveSub` (user) | `user_subscriptions.update` (cancel) | `hadActiveSub ? 1 : 0` |
| 6 | `handleSaveSub` (user) | `user_subscriptions.insert` | 1 |
| 7 | `handleSaveSub` (org) | `organization_subscriptions.update` (cancel) | `hadActiveSub ? 1 : 0` |
| 8 | `handleSaveSub` (org) | `organization_subscriptions.insert` | 1 |
| 9 | `handleRemoveSub` (user) | `user_subscriptions.update` (cancel) | `hadActiveSub ? 1 : 0` |
| 10 | `handleRemoveSub` (org) | `organization_subscriptions.update` (cancel) | `hadActiveSub ? 1 : 0` |

★ **함정 회피**: cancel 4곳은 활성 구독이 원래 없으면 **0행이 정상**이다. 무조건 실패 처리하면 정상 동작을 실패로 오인해 새 버그를 만든다. 화면의 `userSubs`/`orgSubs` 로컬 상태로 활성 구독 존재 여부를 판단해 조건부 계약을 걸었다.

★ **검토한 위험 — 거짓 실패 가능성**: `.select()` 의 RETURNING 은 SELECT 정책의 지배를 받는다. SELECT 가 막히면 쓰기 성공인데도 0행이 되어 **거짓 실패**가 날 수 있다. `profiles` 는 `Users can read all profiles USING(true)`, 나머지 3테이블은 관리자에게 `ALL` 정책이 있고 실제로 화면이 목록을 정상 표시하므로 이 경로는 성립하지 않는다.

### ③ 서버 이관 설계 제안 (구현 안 함 — ANU 판단 요청)

10곳을 서버로 옮기는 건 이번 범위 밖이다. 다만 **가장 값싸고 두 문제를 동시에 푸는 경로**를 제안한다.

**권고: Supabase `SECURITY DEFINER` RPC 단일 관문** (Edge Function 이나 `server/**` 신설보다 우선)

- 근거 1 — **선례가 이 저장소에 이미 있다**: `20260822T010001_task2999_push_rpc_step1.sql` 이 `SECURITY DEFINER` RPC 2종을 도입했고, `chat_gate_info` RPC 도 운용 중이다. 새 배포 표면을 만들지 않는다.
- 근거 2 — **위 ②의 컬럼 과다권한(ANU 판단 항목 #2)을 구조적으로 해소한다**: `admin_assign_user_org(p_user_id, p_org_id)` 같은 함수는 `organization_id` **외 컬럼을 애초에 건드릴 수 없다**. RLS 정책으로는 불가능했던 컬럼 범위 제한이 함수 시그니처로 자연히 달성된다.
- 근거 3 — **0행 모호성을 없앤다**: 함수가 영향 행 수를 명시적으로 반환하거나 `RAISE EXCEPTION` 을 던지면, "거부"와 "대상 없음"이 더 이상 같은 0행으로 뭉개지지 않는다.
- 근거 4 — 감사 로그 삽입 지점이 한 곳으로 모인다. 현재 관리자 조작은 감사 기록이 없다.
- 단계: 1) `profiles` 배정/해제 2건을 RPC 로 이관 → 2) 구독 4건 → 3) organizations 2건. 각 단계마다 프론트는 `supabase.rpc()` 한 줄 교체.
- 이번 PR 의 `.select()` 반영행 검증은 **RPC 이관 후에도 유효**하다(버리는 작업이 아니다).

## L1 스모크테스트

- **서버 재시작**: 해당없음 (프론트엔드 변경 + 미적용 DDL. 배포는 ANU 소관)
- **API 응답 확인**: 프로덕션 DB 직접 프로브로 대체 — `psycopg2` RLS 컨텍스트 재현에서 정책 추가 전 `rowcount=0, error=NONE`, 트랜잭션 적용 후 `rowcount=1` 실측 (`/tmp/task3042-db-verify.log`)
- **스크린샷**: `/home/jay/workspace/memory/reports/task-3042-shots/` (PNG 5장 + E2E 스크립트)
  - `A-3-0rows-RESULT-intercepted.png` — 다이얼로그가 **열린 채** 빨간 토스트: **"배정 실패 / 사용자 배정: 권한이 없거나 대상이 존재하지 않아 반영되지 않았습니다 (0행)"**
  - `B-3-1row-RESULT-intercepted.png` — 다이얼로그가 닫히고 **"배정 완료"**
  - ★ 팀장이 두 PNG 를 **직접 열어 육안 확인**했다. 로그인월/빈 화면이 아니라 실제 관리자 조직관리 화면이다.
  - ★ **한계 명시**: Playwright `page.route()` 로 Supabase REST 응답을 **인터셉트한 하네스**다(파일명 `intercepted`). `assertAffected` 가 실제 React 렌더 경로에서 0행/1행에 따라 다른 토스트를 보여준다는 것을 증명하며, **프로덕션 실사용 검증이 아니다.**

## 검증 결과 (clean 체크아웃 `/tmp/insuro-seal-3042` 재실행)

| 항목 | 결과 |
|---|---|
| `npx tsc --noEmit` | **exit 0** (에러 0) |
| `npx vitest run` (base `2c5c67b`) | 97 files / **1470 passed** |
| `npx vitest run` (최종 `ea4644e`) | 99 files / **1489 passed, 0 failed** |
| 회귀 | **0건** (증가분 +2 files / +19 tests 전부 신규) |
| 레드팀 스캔 (`red-team-auto-review.py`) | `risk_level: low`, 취약점 **0건** |

★ `code-validator.py all` 의 Code Quality ❌ 는 **도구 한계**로 판별했다 — 손대지 않은 base 원본 `AdminOrganizations.tsx` 와 무관한 `Privacy.tsx` 도 동일하게 ❌ 가 난다(TSX 파서 부재). TSX 의 권위 판정은 `tsc --noEmit` 이며 exit 0 이다. 근거 없이 통과 처리하지 않았다.

## 수정 파일별 검증 상태 (절대경로)

| 파일 | 상태 |
|---|---|
| `/home/jay/projects/InsuRo/src/pages/AdminOrganizations.tsx` | 쓰기 10곳 반영행 검증 · tsc 0 · 변이 6종 봉인 |
| `/home/jay/projects/InsuRo/src/pages/__tests__/AdminOrganizations.assertAffected.test.ts` | 헬퍼 단위 6건 GREEN |
| `/home/jay/projects/InsuRo/src/pages/__tests__/AdminOrganizations.silentFailure.test.tsx` | 호출부 렌더 5 + 구조 불변식 8 = 13건 GREEN |
| `/home/jay/projects/InsuRo/supabase/migrations/20260828T000001_task3042_profiles_admin_update_policy.sql` | 108줄 · 트랜잭션 시험 3종 통과 · **미적용** |
| `/home/jay/projects/InsuRo/supabase/migrations/20260828T000000_task3042_profiles_admin_update_policy_ROLLBACK.sql` | 23줄 · 신규 정책만 DROP |

## 게이트 (Lv.2)

- **G1 설계**: PASS — 변경 5파일 전부 `allowed_resources` 범위. `AdminOrganizations.tsx` 를 건드리는 다른 진행 태스크 **0건**(`memory/tasks/*.md` 전수 grep).
- **G2 구현**: PASS — 모리건(테스터) QC 수행. 기능 테스트(호출부 렌더 + 구조 불변식) 실행. 팀장 독립 변이 6종 교차검증.
- **G3 머지**: **PR #264 생성까지가 범위.** 태스크 md 에 "머지는 ANU 가 한다 / 자동 머지 금지" 가 있으므로 DIRECT-WORKFLOW 의 자동 머지 조항을 적용하지 않았다.

## trip-wire 5종 (실측)

| 항목 | 실측 |
|---|---|
| Critical7 | **0** |
| PII net-new | **0** (저장소 커밋물에 자격증명·개인정보 없음. DB 비밀번호를 실제 값으로 대조 검사 → 로그·커밋 유출 0) |
| 회귀 실패 | **0** (1470 → 1489, 기존 전량 통과) |
| forbidden_paths 침범 | **0** |
| nonce | `task-3042` 일치 |

## ★ ANU 판단 대기 항목

1. **scope-guard FAIL 2건** — `src/**/__tests__/**` 패턴의 중간 `**` 를 가드가 리터럴 비교해 영원히 미매칭. 도구 결함이며 실제 범위 이탈 아님. 자가해소 금지 규칙에 따라 근거만 남김.
2. **`profiles` 전 컬럼 관리자 UPDATE 허용이 과다권한인지** — 컬럼 GRANT 는 role 단위라 불가함을 실험으로 확정. 진짜 컬럼 강제는 `BEFORE UPDATE` 트리거(OLD/NEW 비교) 또는 위 ③의 RPC 이관이 유일한 경로.
3. **DDL 적용** — SQL 2파일 제출 완료. 적용은 ANU 권한.
4. **머지** — PR #264 draft. 머지는 ANU.

## 모델 사용 기록

| 팀원 | 역할 | 모델 | 비고 |
|---|---|---|---|
| 루 (Lugh) | 백엔드 — DB 재현 + DDL + 트랜잭션 검증 | sonnet | |
| 브리짓 (Brigid) | 프론트엔드 — 쓰기 10곳 | sonnet | |
| 아네 (Aine) | UX/UI — 브라우저 E2E 스크린샷 | sonnet | |
| 모리건 (Morrigan) | 테스터 — 봉인 테스트 + 변이 (2회) | sonnet | |
| 다그다 (Dagda) | 팀장 — 설계/분배/검토/독립 변이 검증 | opus | 코딩 직접 수행 0 |

haiku 미사용 (전 작업이 판단·검증 비중이 높아 sonnet 이상 필요).

## dev3-team 추가 항목

- **GLM 코드 품질**: GLM 미사용 (Task tool 팀원 위임으로 수행).
- **버그 발견/수정**:
  1. `profiles` 관리자 UPDATE 정책 부재 → 정책 SQL 작성(적용 대기). **해결책** 제출 완료.
  2. 쓰기 10곳 조용한 실패 → 반영행 검증으로 **해결됨**.
  3. 봉인 구멍(M5, `min:1`→`min:0` 생존) → 팀장 독립 변이로 발견, `min` 계약 불변식 추가로 **해결됨**.
  4. `task-scope-guard.sh` 글로브 결함(중간 `**` 미매칭) → 범위 밖이라 수정하지 않음. ANU 판단 대기 (**미해결**, 의도적).

## 비고

- 회장 제보 대비: **증상(정책)과 원인(조용한 실패) 양쪽 모두 처리.** 다만 DDL 이 ANU 에 의해 적용되기 전까지 배정 기능은 여전히 동작하지 않는다 — 대신 이제 **화면이 실패를 정직하게 알린다.**
- 메인 저장소 `/home/jay/projects/InsuRo` 는 수정하지 않았다(cron 이 매일 06:00·08:00 직접 실행). 전 작업을 worktree 에서 수행.
- 원격 반영 경로는 v3.6 하네스 제약에 따라 `worktree_manager.py finish --action pr` 내부 경로 + `gh api ... /pulls` 를 사용했다.

## 커밋 이력

| 커밋 | 내용 |
|---|---|
| `239f4fd` | 브랜치 개설 (세션 조기종료 대책) |
| `905688a` | 루: profiles 관리자 UPDATE 정책 DDL + 트랜잭션 검증 |
| `5df0ce1` | 브리짓: 조직관리 쓰기 10곳 반영행 검증 |
| `a0ba1e0` | 모리건: 호출부 봉인 테스트 + 변이 2종 검증 |
| `ea4644e` | 모리건: 봉인 보강 — 쓰기 지점별 min 계약 불변식 |

## 종결 게이트 실측 (finish-task.sh, FINALIZE_ONLY=1)

- **QC 게이트 (TRUST 5종)**: 전 항목 `passed: true` — `pyright_check` / `scope_check` / `schema_contract` / `data_integrity` / `file_check` / 독립 `api_health`. `.qc-done` 생성됨.
- **scope-guard**: **FAIL 2건 → 머지 차단 + `.escalate` 생성.** 위 §3 에서 도구 결함으로 판별한 그 2건이며 자가해소하지 않았다.
  - `memory/events/task-3042.scope-violation.json` · `task-3042.escalate` · `task-3042.failure-envelope.json`
  - 머지 금지 태스크이므로 **차단이 걸린 상태 자체는 무해**하다. ANU 의 override 판단이 필요하다(`task-3042.anu-override.json`).
- ★ **부수 관측 (범위 밖, ANU 참고)**: `failure-envelope.json` 의 귀속이 틀렸다 — `"team": "dev4-team"`, `"bot": "vishnu"` 로 기록됐으나 실제 수행은 **dev3-team / 다그다**다. 또 `failure_kind` 가 `scope_violation_count_5` 인데 실제 위반은 **2건**이다(diff 파일 5개를 센 것으로 보인다). 종결 스크립트 소유가 아니므로 손대지 않았다.
- **시크릿 스캔**: 커밋 트리 5개 파일 전수 `git show` 대조 → DB 비밀번호 유출 **0건**. 컴파일 산출물(`.pyc`/`dist`/`min.js`) 커밋 **0개**.

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


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

