# 관리자 RBAC 격리 확인 스택

관리자 RBAC 구현(브랜치 feat/admin-rbac, 마일스톤 1~11 완료 · 최종 리뷰 끝)을 사람이 브라우저로 확인하려고 만든 격리 스택이다.
합성 데이터만 쓰고, 운영 · stg 에 접속하지 않는다. 모든 산출과 비밀은 이 디렉터리(L) 아래에만 있다.

용어: **OFF** = 커밋 상태 그대로 (RBAC 스위치 전부 끔). **ON** = 아래 스위치를 켠 사본 (임시 워크트리에서 빌드).

## 1. 기동 · 확인 · 내리기

- 기동: `bash L/up.sh` (처음 약 2분, 이후 몇 초 · 떠 있는 것은 건너뜀). 끝에 상태 표와 로그인 스모크가 나온다.
- 상태: `bash L/status.sh` (유닛 · 포트 · 컨테이너 · 8 계정 로그인 · 화면 index · 대표 RBAC 차이 1건).
- 내리기: `bash L/down.sh` (유닛 6개 stop · 컨테이너 rm · `.env.live` · `run/fcm.pem` 삭제). `--remove-worktrees` 를 주면 임시 워크트리도 제거한다. 기본은 남긴다.
- 메모리: `up.sh` 는 MemAvailable 이 7GB 미만이면 기동하지 않는다. 각 유닛은 `MemoryMax=3G`.
- 접속 (사람): VSCode SSH 포트 포워딩으로 **5197 · 5297** 두 포트만 열고 브라우저에서 `http://localhost:5197` (OFF), `http://localhost:5297` (ON).
  포트는 127.0.0.1 에만 열린다 (0.0.0.0 금지). 같은 브라우저에서 두 스택을 번갈아 써도 쿠키가 섞이지 않는다 (프록시가 포트별 접두를 붙인다).
- 계정표: `L/accounts.txt` (0600, 실행마다 새 비밀번호). 아래 3절의 표와 같고 비밀번호가 들어 있다.

## 2. 무엇이 어디서 뜨는가

| 구성 | 위치 | 포트 | 유닛 · 컨테이너 |
|---|---|---|---|
| DB | docker `rbac-live-mysql` (mysql:8.0.42, 시간대 +09:00, restart=no) | 127.0.0.1:13392 | 컨테이너 |
| Redis | docker `rbac-live-redis` (redis:7) | 127.0.0.1:16392 | 컨테이너 |
| 서버 OFF | 워크트리 `.worktrees/rbac-live-off-srv` (detached HEAD 59523ba7) | 7201 | `rbac-live-srv-off` |
| 서버 ON | 워크트리 `.worktrees/rbac-live-on-srv` (HEAD + 아래 스위치) | 7202 | `rbac-live-srv-on` |
| 관리자 화면 OFF | 워크트리 `.worktrees/rbac-live-off-adm` (detached HEAD ca73d7e8) | vite 5227 | `rbac-live-adm-off` |
| 관리자 화면 ON | 워크트리 `.worktrees/rbac-live-on-adm` (+ 스위치) | vite 5327 | `rbac-live-adm-on` |
| 프록시 OFF | `proxy.js` · `/__api/*` → 7201 (접두 제거), 그 밖 → 5227 | 127.0.0.1:5197 | `rbac-live-proxy-off` |
| 프록시 ON | `proxy.js` · `/__api/*` → 7202, 그 밖 → 5327 | 127.0.0.1:5297 | `rbac-live-proxy-on` |

워크트리는 모두 `/home/madsc/work/insuniverse-temp/.worktrees/admin-rbac/.worktrees/` 아래에 있다. 저장소 추적 파일은 바꾸지 않았다.

ON 에서 편집한 줄 (워크트리 안에서만, 패치 기록은 `on-build-srv.patch` · `on-build-adm.patch`):
- 서버 `access-control.constants.ts`: `SCOPE_ENFORCE_SALES_CORP`, `ENFORCE_AREA_ACCOUNT_TOOLS`, `ENFORCE_AREA_SALES_CORP`, `ENFORCE_AREA_MONEY_FC` 를 `true`
- 서버 `menu-permission-map.ts`: `MENU_SOURCE_DERIVED = true` (메뉴 파생 스위치)
- 관리자 `permissionScreen.ts`: `PERMISSION_SCREEN_ENABLED = true` (권한 관리 화면)
- 서버 `super-admin.module.ts`: `PermissionAdminController` · `PermissionAdminService` 등록 (2026-10-28 추가 — 권한 관리 화면을 데이터와 함께 보려고. 커밋에는 없다)
- 서버 `route-permission-map.ts`: `GET /admin/permission/catalog` · `history` · `impact` · `roles` · `roles/:athId` 를 `rbac.manage` 행으로 추가 (2026-10-28 재기동). 커밋의 기본 거부(ENFORCE_UNMAPPED_ADMIN_DENY)가 맵 밖 경로를 최고관리자 포함 403 으로 닫아서 필요했다. ON 워크트리에서만.
- `ACCESS_CONTROL_MODE = 'observe'` 는 코드 어디에서도 읽히지 않아 그대로 둠 (grep 확인).

서버 실행 환경 (`run-server.sh` · `listen-preload.cjs`):
- `env -i` 로 새로 만든 환경. 개발자 셸의 자격증명은 서버에 닿지 않는다.
- 외부 발송 · 결제 · 본인인증 · 메일 · 웹훅 주소는 전부 `http://127.0.0.1:9` 로 덮었다 (닫힌 포트). 시크릿 키는 넣지 않았다 (호출하면 실패).
- 스케줄러는 preload 가 `addCronJob` · `addInterval` · `addTimeout` 등록을 무력화하고 이미 떠 있는 타이머를 멈춘다 (기동 로그 `scheduler_patched=3`).
- `NODE_ENV=dev`, `TZ=Asia/Seoul`, `CAR_VIEW_ADMIN_IDS=1,2`.

DB 적재 (`db-init.sh`):
- 스키마 · 저장 함수 (`security-review/insuniverse-isolated/initdb` 의 읽기 전용 덤프).
- 덤프에 없는 표는 drizzle 마이그레이션 파일로 보충한다 (0028_ddx_verdict · 0031_admin_audit · 0032_agency_policy · 0032_sales_commission_deduction · 0033 · 0034 · 0035 · 0036_auth_permission) + `tn_admin.adm_is_super` (m10 과 같은 DDL). 실패 목록은 `logs/db-migrate.log` (이번 실행은 0건).
- 합성 테넌트 · 형제 조직 · AO 보강 (기존 마일스톤의 합성 시드 파일 재사용).
- 권한 첫 버전: 실제 적재 스크립트 `permission-first-version.ts --ath-id 1 --apply` (묶음 1 = 최고관리자 전용을 뺀 103 코드). 묶음 2 · 3 은 수기 행.
- **합성 메뉴 트리**: 저장소에 운영 `tn_menu` 가 없다 (`menu-parity.expected.json` 의 note). 그래서 관리자 라우터의 정적 경로로 158 행을 만들었다 (`gen-menu.mjs`). 운영 메뉴와 다르다.

## 3. 계정 (accounts.txt 와 같은 표)

| 키 | 신원 | 로그인 화면 (탭) | 아이디 | 권한 · 범위 |
|---|---|---|---|---|
| super | 최고관리자 (지정, adm_id 1, adm_is_super=1) | `/manage/login` (통합관리자) | rbac-super | 묶음 1 + 최고관리자 전용 |
| adm-full | 내부 관리자 · 권한 묶음 있음 (adm 2) | `/manage/login` (통합관리자) | rbac-adm-full | 묶음 1 (최고관리자 전용 코드 제외) |
| adm-few | 내부 관리자 · 권한 거의 없음 (adm 3) | `/manage/login` (통합관리자) | rbac-adm-few | 묶음 3 = `user-admin.read` 1개 |
| corp-a | 기업 관리자 A (agc 9001 · 합성기업A) | `/login` (기업관리자) | rbac-corp-a | 기업 A 전체 |
| corp-b | 기업 관리자 B (agc 9002 · 합성기업B) | `/login` (기업관리자) | rbac-corp-b | 기업 B 전체 |
| org-a1 | 조직 관리자 (회원 9311 · 조직 SYNORGA1, 하위 SYNORGA1C) | `/login` (조직관리자) · 기업코드 SYNCORPA | rbac-org-a1 | SYNORGA1 부분트리 |
| sales-a | 영업 A (sag 9101 · 기업 A 담당) | `/manage/login` (채널관리자) | rbac-sales-a | 영업 A 담당 |
| sales-b | 영업 B (sag 9102 · 기업 B 담당) | `/manage/login` (채널관리자) | rbac-sales-b | 영업 B 담당 |

- 묶음 2 (`합성-조회묶음` · `user-admin.read` · `money-admin.read`) 은 계정이 없다. 권한 관리 화면에서 묶음을 볼 때 쓸 수 있다.
- 형제 조직 SYNORGA10 (접두 충돌: `/SYNORGA1/` 와 다름) 의 회원 9303 과 기업 B 회원 9304 가 있다.
- 로그인 필드: 관리자 · 최고관리자 `admLoginId/admPassword`, 기업 `agcLoginId/agcPassword`, 조직 `agcCorporateCode + memLoginId/memPassword`, 영업 `sagLoginId/sagPassword`.

## 4. 확인 시나리오

관찰 열: **실측** = 이번 실행에서 두 스택 모두 확인 · **코드** = 코드 근거만 (파괴적이라 실행하지 않음 · 미실측).

| No | 신원 | 할 일 (화면 · 요청) | OFF | ON | 근거 |
|---|---|---|---|---|---|
| 1 | 최고관리자 | 관리자 목록 · 회원 · 머니 · 영업 · 알림 화면 열기 | 전부 200 | 전부 200 | 실측 |
| 2 | 내부 관리자 (묶음 있음) | 관리자 목록 (`/admin/admin`, `rbac.manage` 는 최고관리자 전용) | 200 | **403** | 실측 |
| 3 | 내부 관리자 (묶음 있음) | 알림 · 회원 · 머니 · 영업 목록 | 200 | 200 | 실측 |
| 4 | 내부 관리자 (거의 없음) | 회원 목록 / 머니 · 영업 · 기업 목록 / 알림 / 관리자 목록 | 전부 200 | 회원만 200, **나머지 403** | 실측 |
| 5 | 영업 A | 기업 B 소속 주문의 분석 기본 (`/admin/analyze/9702/basic?ansType=ANS001&page=1`) | 200 (현행 누출) | **403** `E0607 reason=scope` | 실측 (스모크 포함) |
| 6 | 영업 A | 자기 담당 기업 A 의 주문 (`/admin/analyze/9701/...`) | 200 | 200 | 실측 |
| 7 | 영업 B | 기업 A 의 머니 이력 (`/admin/money/corp/9001/history`) | 200 | **403** | 실측 |
| 8 | 영업 A | 기업 B 의 머니 이력 (`/admin/money/corp/9002/history`) | 200 | **403** | 실측 |
| 9 | 영업 A · B | 영업대리점 단건 (`/admin/sales/9102`, `/admin/sales/9101` 모두) | 200 | 200 | 실측 (**ON 에서도 다른 영업의 단건이 보인다**, 아래 5절) |
| 10 | 기업 A | 기업 B 의 지급 내역 단건 (`/admin/money/issue/9202`) | 403 | 403 | 실측 (기존 가드 "다른 기업의 지급 내역은 조회할 수 없습니다") |
| 11 | 기업 A | 지급 내역 목록 (`/admin/money/issue`) | 자기 기업만 | 자기 기업만 | 실측 |
| 12 | 조직 관리자 (SYNORGA1) | 조직 목록 (`/corp/organizations`) | SYNORGA1 · SYNORGA1C 만 (형제 SYNORGA10 없음) | 같음 | 실측 |
| 13 | 조직 관리자 | 기업 B 주문 분석 (`/admin/analyze/9702/basic`) | 403 | 403 | 실측 (기존 가드) |
| 14 | 최고관리자 · 묶음 있음 · 거의 없음 | 차량 열람 (`/admin/analyze/9701/car-basic`) | 200 · 200 · 403 | 200 · 200 · 403 | 실측 (`CAR_VIEW_ADMIN_IDS=1,2`) |
| 15 | 최고관리자 · 묶음 있음 | 권한 관리 데이터 (`/admin/permission/catalog`, `roles`, `history`, `impact`) | 404 | 404 | 실측 (미등록 · 6절) |
| 16 | 최고관리자 · 묶음 있음 | ON 의 권한 관리 화면 5종 (묶음 목록 · 이력 · 묶음별 보기 · 관리자 상세 권한 칸 · 영향 미리보기) | 화면 없음 | 라우트만 켜지고 데이터 API 404 | 라우트 등록은 코드, 404 는 실측 (화면 렌더는 미확인) |
| 17 | 최고관리자 지정 상태에서 비최고관리자 | 최고관리자 계정 삭제 시도 → `E0827` | 같음 (두 빌드 모두 HEAD) | 같음 | 코드 (`admin.service.ts` 154행 근거 · 삭제는 실행하지 않음) |
| 18 | 권한 묶음 쓰기 (부여 상한 · 자기 계정 · 고권한 계정 가드레일) | 권한 관리 쓰기 | 도달 불가 | 도달 불가 (쓰기 경로 미등록) | 코드 (`permission-guardrail` 은 미등록 컨트롤러에서만 호출) |

## 5. ON 에서 달라지는 것 · 달라지지 않는 것

달라짐 (실측):
- 강제한 영역 (계정 도구 · 영업·기업 · 머니·FC) 에서 권한 없는 신원은 403 이다 (묶음 3 의 계정 4번).
- 영업 범위 밖의 주문 분석 · 머니 이력 단건은 403 (`E0607 reason=scope`).

그대로 (실측):
- 기업 단건 가드 (기존), 조직 목록 부분트리 (기존), 영업 목록 (기업 A 담당 영업 A 는 9001 만 본다).
- 강제 영역 밖 (예: 분석 목록 `/admin/analyze/9701/basic`) 은 권한이 없는 묶음 3 계정도 200 이다. ON 이라도 그 영역은 아직 판정하지 않는다 (관찰만).
- 영업대리점 단건 (`/admin/sales/:id`) 은 ON 에서도 다른 영업 것이 보인다 (시나리오 9). 강제 대상이 아니라 현행 그대로다.

메뉴 파생 스위치 (미확인): ON 의 `/admin/menu/my` 는 OFF 와 같은 모양이었다 (묶음 3 은 회원 관리 하나). 파생 응답이 실제로 달라지는지는 확인하지 않았다.

## 6. 알려진 한계 · 주의
- (2026-10-28 재기동) ON 빌드를 새 HEAD (서버 59523ba7 · 관리자 ca73d7e8) 위에 다시 만들었다. 권한 관리 데이터 API 는 최고관리자 200 · 권한 없는 관리자 403 이다 (2절의 맵 행 추가 뒤). 아래 「권한 관리 쓰기 API 가 등록되지 않았다」 문단은 낡았다: 컨트롤러는 ON 에 등록되어 있다. 쓰기 경로 (`POST /admin/permission/role/:athId`) 는 실행하지 않았다. 실측 결과 · 발견은 `.claude/notes/rbac-redesign/10-live-capture.md` (관리자 저장소 바깥 부모 저장소).

- (2026-10-28) ON 서버에는 아래 두 클래스를 등록했다 — ON 의 `/admin/permission/*` 는 최고관리자 200 · 다른 관리자 403 `super-only`. OFF 는 여전히 404.
- **권한 관리 쓰기 API 가 등록되지 않았다.** `PermissionAdminController` 와 `PermissionAdminService` 는 어느 모듈에도 없다 (R16 CSRF 전 · PRD 대로). 그래서 ON 의 권한 관리 화면은 데이터가 404 로 비어 있고 쓰기는 도달할 수 없다. 켜 보려면 관리자 모듈의 `controllers` · `providers` 에 두 클래스를 등록한다 (ON 워크트리에서만 · 미검증).
- 합성 메뉴 트리 (운영과 다름). 합성 데이터라 분석 행 응답은 대부분 빈 목록이다 (count 0).
- 로그: `logs/rbac-live-srv-*.log` 에 요청 로그가 쿠키 · 요청 본문을 평문으로 싣는다. 로그인 본문의 비밀번호도 들어간다. L 밖으로 옮기지 않는다.
- 비밀: `.env.live` · `run/fcm.pem` · `accounts.txt` · `.accounts.tsv` 는 0600. `down.sh` 가 앞의 두 개를 지운다. 계정표는 남는다 (다음 `up.sh` 가 새로 쓴다).
- 외부 호출 차단은 주소 덮기로만 한다. 이 VM 의 외부 접속 가능 여부와 차단이 완벽한지는 확인하지 않았다 (미검증).
- 프록시의 HMR 웹소켓 전달은 구현만 했다. 브라우저에서 HMR 이 붙는지는 확인하지 않았다 (미검증).
- 관리자 화면은 vite dev 서버로 띄운다 (빌드 산출 아님).
- (2026-10-28 검수 반영) 관리자 ON 워크트리를 HEAD `3075915c` (권한 안내 줄을 본문 칸으로 옮긴 커밋, 관리자 저장소) 로 옮기고 스위치 패치(`on-build-adm.patch`)를 다시 적용했다. 관리자 OFF 는 `ca73d7e8` 그대로다 — 안내 줄은 OFF 에서 뜨지 않아 OFF 캡처에 영향이 없다. 바뀐 캡처는 `11-review-recapture.md` 참조 (부모 저장소 notes).
- 서버 원본 워킹트리 (`insuniverse-server`) 에 포맷 전용 변경 4개 (`admin-super.spec.ts` · `admin-version-bump.spec.ts` · `admin.repository.ts` · `admin.service.ts`) 가 있다. 이 스택이 만든 것이 아니다. 빌드는 HEAD 기준 워크트리에서 해서 영향이 없다.
- 세션 시작 시점의 `git status` 는 깨끗했는데 지금은 위 4개가 바뀌어 있다. 원인 미확정 (포맷 훅 추정). 되돌리지 않았다 (다른 작업일 수 있다).

## 7. 파일

- `up.sh` · `down.sh` · `status.sh` — 기동 · 내리기 · 상태
- `lib.sh` — 포트 · 경로 · 유닛 설정
- `db-init.sh` · `gen-accounts.js` · `gen-menu.mjs` — DB 적재 · 계정 · 메뉴
- `run-server.sh` · `listen-preload.cjs` · `run-vite.sh` · `proxy.js` — 실행 래퍼
- `smoke.js` — 로그인 · 화면 · 대표 RBAC 차이 스모크
- `on-build-srv.patch` · `on-build-adm.patch` — ON 스위치 편집 기록
- `accounts.txt` · `.accounts.tsv` · `.env.live` · `logs/` · `run/` — 산출 (0600 · 실행 때마다 새로 생성)
