# 06 · 관리자 접근제어 모델 실측 (2026-08-05)

> **성격**: 사실 기록. 설계 결정도 규약도 아니다. `05-final-spec.md` 를 대체하지 않는다.
> **범위**: `insuniverse-server` 관리자 인가 · `insuniverse-admin` 라우팅/메뉴.
> **동기**: 문서 템플릿 키트에 권한메뉴 축을 도입하려다 **키트 설계가 전제한 권한 모델이 실제와 다르다**는 것을
> 발견했다. 키트 변경은 되돌렸고(사용자 지시), 발견한 사실만 여기 남긴다.
> **미조사**: 프록시·게이트웨이 계층, 런타임 동작, 권한 그룹별 실제 배분(`tn_menu_auth_map` 행).

---

## 1. 메뉴 권한 모델이 actor 마다 다르다 — 3종

| actor | 메뉴 트리 소스 | 코드 네임스페이스 | 항목 수 | 로그인 직후 진입 |
|---|---|---|---|---|
| **admin** | DB `tn_menu` → `GET /admin/menu/my` | `mnu_code` (`SEC001`·`USR004`…) | 79 (2단계) | **메뉴 트리 기반 동적** |
| **sales** | **하드코딩** `salesMenus` | `mnu_code` 와 **같은** 네임스페이스 (`SAL001`·`USR001`…) | 14 | 고정 `/` |
| **corp** | **하드코딩** `corpMenus` + `agcRole` 필터 | **`CRP_*` 별도 네임스페이스** (`CRP_ORG001`·`CRP_STL001`·`CRP_SUB002`·`CRP_SUB003`·`CRP_USR001`) | 5 | 고정 `/corp/organization`(SUPER) · `/user/corp-fc`(ORG) |

앵커
- `insuniverse-admin/src/layouts/AdminLayout.tsx:242` — admin 만 `getMyMenuTree()` 호출
- `insuniverse-admin/src/layouts/AdminLayout.tsx:256` — sales: `filterMenusByAuthority(salesMenus, salesCode)`
- `insuniverse-admin/src/layouts/AdminLayout.tsx:271-272` — corp: `filterMenusByAuthority(corpMenus, …)`
- `insuniverse-admin/src/features/authority/constants/menu.ts:7` `salesMenus` · `:190` `corpMenus` · `:174` `salesCode` · `:269` `corpCode`
- `insuniverse-admin/src/features/login/hooks/useLoginForm.ts:56-69` (admin 동적) · `:111` (sales 고정) · `:118,121` (corp 고정)

**`adminMenus` 상수는 존재하지 않는다** — admin 만 DB 에서 온다.

### 1-1. sales·corp 의 메뉴 필터는 **클라이언트 측**이다

`filterMenusByAuthority(salesMenus, salesCode)` (`AdminLayout.tsx:256`) · 동 corp(`:271-272`)는
브라우저에서 상수 배열끼리 대조한다. **서버는 관여하지 않는다** — sales·corp 로그인은
`getMyMenuTree()` 를 호출조차 하지 않는다(`useLoginForm.ts:111,118,121`).
→ sales·corp 의 "메뉴 권한" 은 **표시 필터일 뿐 인가가 아니다.** 인가는 `@Roles` 와 개별 guard 뿐이다.

**`salesMenus` 트리 14 vs `salesCode` 필터 10 — 실측 차이 규명**

| 항목 | 값 |
|---|---|
| `salesMenus` 트리의 `code` 출현 | 14 (그중 `SUB004` 가 **2회 중복** → 고유 13) |
| `salesCode` 필터 배열 | 10 |
| 트리에만 있고 필터에 없는 것 | **`B2C001` · `B2C002` · `B2C003`** |
| 필터에만 있는 것 | 없음 |

→ B2C 3종은 트리에 정의돼 있으나 필터를 통과하지 못해 **사이드바에 절대 나타나지 않는다**(dead entry).
`SUB004` 중복은 두 위치에 같은 코드를 쓴 것으로 보이며 표시에는 영향이 없다.
**sales 의 실효 메뉴는 10종이다.**

**함의**: "권한 DB 스냅샷 미제공 → 메뉴 권한 전부 `data-dependent-unknown`" 은 **범위 과잉**이다.
미확보는 admin actor 의 그룹별 배분뿐이고, sales·corp 트리는 코드로 100% 확정된다.

**함의 2**: `CRP_*` 는 `tn_menu.mnu_code` 에 없는 네임스페이스다. 코드 사전(`02-identifier-namespaces.md`)의
5 namespace 목록에 이것이 들어 있는지 확인해야 한다.

## 2. 메뉴 권한은 서버 API 를 막지 않는다

메뉴 권한이 정하는 것은 **사이드바에 무엇을 그릴지**와 **로그인 직후 어느 화면으로 보낼지**뿐이다.

- `insuniverse-server/apps/api/src/modules/menu/menu.repository.ts:32-38` — `getMenusByAdminId` 는 `tn_menu ⋈ tn_menu_auth_map ⋈ tn_admin` 조인 결과를 **반환만** 한다
- `insuniverse-server/apps/api/src/modules/menu/menu.controller.ts:25` — `GET /admin/menu/my` 는 `AdminJwtAuthGuard` 만 건다

API 차단은 `@Roles`(`roles.guard.ts:27`)와 개별 guard 소관이다.
→ **메뉴가 안 보이는 관리자도 URL 을 직접 치면 role 조건만 맞으면 API 가 응답한다.**

### 2-1. `GET /admin/menu/all` 무인증 노출 — **stg 실환경에서 확인됨 (2026-08-05)**

> **성격: 정보 노출. 코드 수정은 우산 PRD Out of scope 이므로 여기 기록만 한다.**

`menu.controller.ts:15` 의 `@Get('all')` 에는 `@UseGuards` 가 없다. 전역 guard 도 없다 —
`main.ts` 에 `useGlobalGuards` 없음(`:18-21` 은 bodyParser·cookieParser 뿐), `app.module.ts` 의
provider 는 `APP_PIPE`(ClassValidatorPipe) 하나로 `APP_GUARD` 없음.

Playwright 로 stg 실환경 검증(2026-08-05, 사용자 요청·도메인 제공):

| 요청 | 쿠키 | 결과 |
|---|---|---|
| `GET https://s-api.insuniverse.com/admin/menu/all` | **없음** | **HTTP 200** — 전체 메뉴 트리 JSON 반환 (`mnuId`·`mnuName`·`mnuCode`·`mnuUrl`·`mnuDepth`·`mnuParentId` 전부) |
| `GET https://s-api.insuniverse.com/admin/authority` | 없음 | HTTP 401 (대조군) |

→ **리버스 프록시 계층도 막지 않는다.** 앞서 남겼던 "프록시 계층 미확인" 단서는 해소됐다.
대조군이 401 이므로 전역 인증 자체는 동작하며, 이 엔드포인트만의 국소 결함이다.

노출 내용은 관리자 **메뉴 구조 전체**(이름 · 코드 · URL · 계층)다. 자격증명·개인정보는 없다.
다만 관리자 화면 경로가 전수 노출되므로 공격 표면 매핑에 쓰인다.
prod 는 확인하지 않았다(도메인 미제공). **동일 코드가 배포돼 있다면 prod 도 같을 것으로 보나 미검증이다.**

## 3. 라우터가 actor 별로 3분할되어 있다

`insuniverse-admin/src/app/router/index.ts` — `router`(admin) · `salesRouter` · `corpRouter`

| 라우터 | 고유 라우트 | `tn_menu` 에 있는 것 | admin 라우터에 없는 것 |
|---|---|---|---|
| `router` (admin) | 209 | 63 | — |
| `salesRouter` | 21 | 10 | **0** |
| `corpRouter` | 16 | 5 | **7** |

corp 전용 7개 — `/corp/my-page` · `/corp/organization` · `/money/general`(+`/add`) · `/money/charge-requests` · `/corp/corp-settlement`(+`/:id`)

## 3-1. stg 실측 — 메뉴 코드에 작명 규칙이 3가지 섞여 있고, 제공 덤프가 낡았다

> 2026-08-05, 로그인 상태에서 `GET /admin/menu/all` · `GET /admin/menu/my` 응답 대조.

| 항목 | 값 |
|---|---|
| stg `tn_menu` 총 행 | **104** (1단계 23 · 2단계 **81**) |
| 사용자 제공 덤프 | 102 (1단계 23 · 2단계 **79**) |
| stg 에만 있는 것 | `MNU_LOSS_ANALYSIS`(실손분석관리) · `MNU_LOSS_ANALYSIS_LOG`(실손분석로그관리) |
| 덤프에만 있는 것 | **0** |

→ **덤프가 stg 보다 뒤처져 있다.** 메뉴 인덱스는 덤프가 아니라 실행 중인 DB 를 소스로 삼고 조회 시점을 함께 적어야 한다.

**작명 규칙 3종** — `^[A-Z]{3}[0-9]{3}$` 하나로는 81 중 5가 탈락한다:

| 형태 | 예 | 개수 |
|---|---|---|
| `AAA000` | `SEC001` · `USR004` · `CRP001` | 76 |
| 접두어에 숫자 | `B2C001` · `B2C002` · `B2C003` | 3 |
| `MNU_` + 가변 길이, 숫자 없음 | `MNU_LOSS_ANALYSIS` · `MNU_LOSS_ANALYSIS_LOG` | 2 |

→ `11-index-code` 의 `menu_db.yaml` 정규식은 **책상에서 정하지 말고 live DB 전수에서 역산**한다.

**대조 확인**: 이 계정은 전권이라 `menu/my` 2단계 81 = `menu/all` 2단계 81 로 일치했다.
따라서 "권한이 없으면 트리에서 빠진다" 는 이번 세션에서 **런타임 검증되지 않았다** (§2 코드 근거만 유효).

## 4. `mnu_url` ↔ admin 라우트 — 98% 일치, 고아 메뉴 2건

> stg live DB 기준 재측정(2026-08-05). *(초기 측정은 사용자 제공 덤프에서 URL 을 손으로 옮겨 63개만
> 대조했고 "100%" 라고 적었다. 실측 URL 은 82개이며 아래가 정확한 값이다.)*

```
stg mnu_url: 82 (1단계 1 + 2단계 81) | admin 라우트 고유: 209
정확 일치: 80 (98%)
불일치 2건: /subscription/loss-analysis · /subscription/loss-analysis-log
메뉴당 평균 1.5개 하위 라우트(상세·등록) → 화면 209 : 메뉴 82 = 2.5배
```

**고아 메뉴 2건** — `MNU_LOSS_ANALYSIS` · `MNU_LOSS_ANALYSIS_LOG` 는 stg DB 에 등록되어 있으나
`insuniverse-admin` HEAD 에 **대응 라우트가 없다**. 이 메뉴가 배분된 관리자는 사이드바에서 항목을
보지만 클릭하면 도달할 화면이 없다.

**성격 규명 (2026-08-05)** — 롤백 잔여물이 아니라 **선행 등록**이다:

| 조사 | 결과 |
|---|---|
| `git log --all -S "loss-analysis"` (admin 저장소 전 브랜치·전 이력) | **0건** |
| `insuniverse-server` `apps`·`libs` 내 `loss-analysis`/`lossAnalysis`/`LossAnalysis` | **0건** |

→ 프론트·서버 **어디에도 존재한 적이 없다.** 롤백이었다면 git 이력에 흔적이 남는다.
DB 에만 먼저 넣어 둔 신규 기능 준비로 판단한다. `10-index-git` 의 폐기 판정 필터에 걸리지 않는
**"미구현 선행 등록"** 유형이며, `dead-code-candidate` 와 구별해야 한다(코드가 죽은 게 아니라 아직 없다).

`/` (`COM001`)는 `page.routes.tsx:46` 에 정의돼 있다. 초기 측정에서 불일치로 잡힌 것은
추출 범위를 `routes/domains/` 로만 잡은 탓이며 실제 결함이 아니다.

→ 나머지 80건은 `mnu_url → 라우트 → 페이지 → API → controller` 사슬이 끊기지 않는다.
**메뉴↔코드 대응은 기계 도출이 가능하되, 100% 를 전제하면 안 된다** — 고아 메뉴를 별도로 다뤄야 한다.

## 5. 인가가 엔드포인트 단위로 갈린다

| 항목 | 값 |
|---|---|
| `@Roles` 선언 | **136** / 51 파일 (파일당 2.7) |
| 한 controller 안에 role 조합 2종 이상 | **12 파일** (최대 21선언: `agency-fc-admin.controller.ts`) |
| role 조합 종류 | **5종** — `admin` 62 · `admin,sales,corp` 31 · `admin,corp` 23 · `admin,sales` 12 · `corp` 8 |
| guard stack 프로필 (controller 레벨) | **12종**. 최빈 `AdminJwtAuthGuard,RolesGuard\|admin` 이 34/60 = **57%** |
| admin 계열 controller | 60 / 전체 113 |

**`@Roles` 136 vs 137 분쟁**(`00-protocol` M3) — **136 으로 확정 (2026-08-05)**

| 측정 범위 | 결과 |
|---|---|
| `apps/api/src` 전체 `.ts` | **136** |
| `apps/api/src` `*.controller.ts` 한정 | **136** |
| `apps` + `libs` 전체 | **136** |

controller 밖 선언 **0건**. 137 은 측정 경로에 빌드·의존 산출물이 섞였을 때 나오는 값으로 본다 —
저장소 루트에 `dist/` · `node_modules/` 가 있다. 잔여 작업은 원인 규명이 아니라
**manifest 의 `exclude_paths` 명시**이며 `00-protocol` §분쟁 수치에 기록했다.

guard 6종: `jwt` · `roles` · `corp-readonly` · `corp-analysis-view` · `car-view-admin` · `admin-optional`
(`insuniverse-server/apps/api/src/modules/auth/admin/`)

## 6. 권한 층은 4개이고 층마다 다른 것을 막는다

`analyze-admin.controller.ts` 경로 실측:

| 층 | 강제 지점 | 실패 시 |
|---|---|---|
| 1 · 인증 | `jwt.guard.ts:26,47` (`_aaT` 쿠키) | HTTP 401 |
| 2 · 계정 종류 | `roles.guard.ts:27` | HTTP 403 `접근 권한이 없습니다` |
| 3 · 쓰기 스코프 | `corp-readonly.guard.ts:21,24,33` (`corpScope='ORG'` + 비-GET) | HTTP 403 `조직관리자는 허용된 작업만 수행할 수 있습니다` |
| 4 · 기업 노출 등급 | `corp-analysis-view.guard.ts:47` (`tn_agency.agc_analysis_view < required`) | HTTP 403 `분석관리 화면 접근 권한이 없습니다` |
| (비차단) 메뉴 노출 | `menu.repository.ts:37-38` | 사이드바에서 사라짐. **API 는 응답** |

`ADMIN_VIEW_TOKEN_ENFORCE` 게이트가 `'true'` 일 때의 메시지만 영문이다 —
`admin-view-token.guard.ts:56-57` `invalid or missing admin-view token`.

## 7. `50-domain` 이 이미 흐름·콘솔 문서 10개를 계획하고 있다

`insuniverse-docs-50-domain.prd.md` MVP 원문:

```
docs/20-site/   → 5개 흐름 문서
  01-analysis-flow.md  02-alimtalk-certify.md  03-subscription-payment.md
  04-mypage-account.md  05-content-board.md
docs/30-admin/  → actor 3분할 기반 5개 문서
  01-admin-console.md  02-corp-console.md  03-sales-console.md
  04-authority-management.md  05-treat-monitoring.md
```

`30-admin` 의 "actor 3분할" 은 §3 의 라우터 3분할과 정확히 대응한다.
**절차·사용자 흐름 서술의 자리는 이미 여기에 배정되어 있다** — 다른 PRD 의 문서가 같은 내용을 쓰면 중복이다.

---

## 후속 판단 — 6건 전부 결정됨 (2026-08-05, 사용자)

| # | 항목 | 근거 | 결정 | 반영처 |
|---|---|---|---|---|
| 1 | `04-authz-db-menu.md` 구성 | §1 | **한 문서 안 actor 3절.** 문서 분할 없음(8종 상한 유지) | `20-foundation-access` 반드시 지킬 것 |
| 2 | 메뉴 코드 namespace | §1 | **`menu.yaml` 을 `menu_db.yaml` / `menu_hardcoded.yaml` 로 분리.** `SAL_` 정규식 제거 | `11-index-code` MVP 산출물 표 |
| 3 | `data-dependent-unknown` 범위 | §1 | **admin 그룹별 배분에만 한정.** spec §6-2 에 정정 배너 | `05-final-spec.md` §6-2 · 요약표 · 우산 PRD |
| 4 | `GET /admin/menu/all` | §2-1 | **stg 검증 완료 — 무인증 200 확정.** 코드 수정은 out of scope, 기록만 | 본 문서 §2-1 |
| 5 | `@Roles` 개소 | §5 | **136 확정.** 잔여는 manifest `exclude_paths` 명시 | `00-protocol` §분쟁 수치 |
| 6 | 절차·흐름 서술 | §7 | **`flow-doc@1` 신설.** `50-domain` 의 10개 문서에 부여, 소유 단독 | `doc-template-kit` M7 · `50-domain` |

### 아직 열려 있는 것

| 항목 | 왜 | 필요한 것 |
|---|---|---|
| **prod 의 `/admin/menu/all` 노출 여부** | stg 만 검증했다. 같은 코드가 배포됐다면 prod 도 열려 있을 것으로 보이나 **추정이며 미검증** | prod 도메인 + 사용자 승인 |
| 메뉴 권한 없는 계정의 API 직접 호출 | §2 의 코드 근거는 확정. 이번 세션 계정이 전권이라 **런타임 반증 기회가 없었다** | 권한 제한 admin 계정 |
| corp·sales 포털 런타임 확인 | §1 은 코드 기준. 실제 사이드바·진입 화면은 미확인 | corp / sales 계정 |
| `menu_db.yaml` 정규식 | 작명 규칙 3종 확인(§3-1). live DB 전수에서 역산해야 확정된다 | `11-index-code` 착수 시 |

### 이번 세션에서 닫힌 것

| 항목 | 결과 | 절 |
|---|---|---|
| `GET /admin/menu/all` 무보호 | **stg 200 확정** (대조군 401) | §2-1 |
| `@Roles` 136 vs 137 | **136 확정** (3방향 일치, controller 밖 0건) | §5 |
| `salesMenus` 14 vs `salesCode` 10 | **규명** — `SUB004` 중복 1 + 필터 미포함 `B2C*` 3. 실효 10종 | §1-1 |
| 고아 메뉴 2건의 성격 | **선행 등록 확정** — git 이력·서버 코드 모두 0건 | §4 |
| `mnu_url` ↔ 라우트 일치율 | **80/82 (98%)** — 초기 "100%" 는 대조 누락이었다 | §4 |
| `flow-doc@1` 설계 일체 | **산출 완료** (`doc-template-kit` M7). §6 4층 + 비차단 메뉴 노출이 `config.auth_layers` 5행이 되었고, §1 actor 3종이 `flows[].actor` 필수의 근거가 되었으며, §4 고아 메뉴 2건 때문에 화면 참조는 `refs` 로 걸되 100% 대응을 전제하지 않는다. 상호참조는 상대경로+`#id`, `status` 는 `config.status_precedence` 지배 순서, lint 규칙 3개 신설 | §1 · §4 · §6 · §7 |
