---
template: flow-doc@1
doc_id: 04-authority-management
title: 권한 그룹·메뉴 노출·권한 변경 반영
config: ../_meta/template-config.yaml
last_verified: fb001e1d
flows:
  - id: authority-group-create
    actor: 관리자
    status: active-prod
    status_source: ./00-ia.md#admin-security-authority
    evidence: [S-route, S-roles, S-schema]
    limitations:
      - 그룹에 실제로 어떤 메뉴가 배분돼 있는지는 tn_menu_auth_map 행에 있고 그 스냅샷은 이 창에 없다. 절차 코드는 확정이고 배분값만 미상이다
      - 삭제가 매핑 행을 함께 지우는지는 저장소 코드에서 확인되지 않았다. DB 제약(외래키 cascade)의 실재는 이 창에서 대조하지 않았다
    refs:
      - ./00-ia.md#admin-security-authority
      - ./00-ia.md#admin-security-authority-add
    anchors:
      - insuniverse-server/apps/api/src/modules/authority/authority-adimin.controller.ts:30
      - insuniverse-server/apps/api/src/modules/authority/authority.repository.ts:32
      - insuniverse-server/apps/api/src/modules/menu/menu.repository.ts:32
      - insuniverse-server/apps/api/src/modules/auth/admin/roles.guard.ts:27
      - insuniverse-server/apps/api/src/modules/auth/admin/jwt.guard.ts:26
      - insuniverse-server/apps/api/src/modules/auth/admin/corp-readonly.guard.ts:21
      - insuniverse-server/apps/api/src/modules/auth/admin/corp-analysis-view.guard.ts:34
  - id: authority-menu-distribution
    actor: 관리자
    status: active-prod
    status_source: ./00-ia.md#admin-security-authority-add
    evidence: [S-route, S-schema, S-roles]
    limitations:
      - 메뉴 트리의 전수는 live DB 의 tn_menu 에 있고 저장소 사본은 전수 열거가 아니다. 어느 코드가 어느 화면에 대응하는지의 전건 매핑은 이 문서가 판정하지 않았다
    refs:
      - ./00-ia.md#admin-security-authority-add
      - ./00-ia.md#admin-user-admin
    anchors:
      - insuniverse-server/apps/api/src/modules/authority/authority.repository.ts:47
      - insuniverse-server/apps/api/src/modules/menu/menu.repository.ts:32
      - insuniverse-server/apps/api/src/modules/authority/authority-adimin.controller.ts:51
  - id: authority-api-exposure
    actor: 관리자
    status: active-prod
    status_source: ./00-ia.md#admin-user-admin
    evidence: [S-guard, S-roles, S-route]
    limitations:
      - 화면 안에서 버튼·컬럼이 권한에 따라 사라지는 구현은 클라이언트 상수 필터로 관측했다. 그 필터가 모든 화면에 같은 형태로 있는지는 전수를 세지 않았다
    refs:
      - ./00-ia.md#admin-user-admin
      - ./00-ia.md#admin-user-admin-add
    anchors:
      - insuniverse-server/apps/api/src/modules/auth/admin/roles.guard.ts:22
      - insuniverse-server/apps/api/src/modules/menu/menu.controller.ts:15
      - insuniverse-server/apps/api/src/modules/menu/menu.repository.ts:32
      - insuniverse-server/apps/api/src/modules/auth/admin/corp-readonly.guard.ts:24
  - id: authority-session-propagation
    actor: 관리자
    status: active-prod
    status_source: ./00-ia.md#admin-manage-login
    evidence: [S-route, S-guard, S-schema]
    limitations:
      - 세션 무효화 경로(강제 로그아웃·토큰 폐기)의 실재를 이 창에서 확인하지 못했다. 관측한 것은 사이드바 갱신 시점 하나다
    refs:
      - ./00-ia.md#admin-manage-login
      - ./00-ia.md#admin-user-admin
    anchors:
      - insuniverse-server/apps/api/src/modules/menu/menu.controller.ts:25
      - insuniverse-server/apps/api/src/modules/auth/admin/jwt.guard.ts:26
---

# 권한 그룹·메뉴 노출·권한 변경 반영

> **admin 은 자동화된 테스트 근거가 없다.** `insuniverse-admin` 동결 HEAD `fb001e1d` 에 테스트 파일이 한 건도 없다. 이 디렉터리의 모든 문서는 라우트 등록과 컴포넌트 실존과 소스 읽기만을 근거로 하며 화면 축에 `S-test` 등급을 단 항목이 하나도 없다. 절차의 단계 순서가 런타임에서 실제로 그 순서로 도는지는 이 문서가 판정하지 않았다.

이 문서는 권한 그룹을 만들고 메뉴를 배분하고 그 변경이 언제 반영되는지를 담는다. 화면 구조의 정본은 [화면 인벤토리](./00-ia.md)이며, 각 콘솔의 절차는 [통합 콘솔](./01-admin-console.md) · [기업 콘솔](./02-corp-console.md) · [대리점 콘솔](./03-sales-console.md)이 각각 진다.

**이 문서의 중심 주장은 하나다 — 메뉴 노출은 인가가 아니다.** 사이드바에서 메뉴가 사라지는 것과 그 API 가 막히는 것은 다른 층이고, 이 저장소에서는 앞의 것만 권한 그룹이 정한다. 그 사실이 세 흐름(`authority-menu-distribution` · `authority-api-exposure` · `authority-session-propagation`)에 각각 다른 각도로 나타난다.

**권한 규칙의 정본은 이 문서가 아니다.** 역할 매트릭스와 층별 계약은 `docs/02-access/` 소유이며 이 창에는 그 디렉터리가 한 파일도 없다. 부재 문서로 링크를 걸지 않고 경로만 산문으로 적는다 — 그 문서가 서면 이 문단이 링크로 바뀐다.

**인증 층에는 이 문서가 검증하지 않은 우회 분기가 둘 있다.** `admin/jwt.guard.ts:33` 은 핸들러 메타데이터 `isPublic` 이 참이면 토큰을 읽기 전에 통과시키고, `admin/jwt.guard.ts:42` 는 토큰 검증이 실패해도 `isDevSwaggerRequest` 가 참이면 `DEV_ADMIN`(`admId 19`)을 주입하고 통과시킨다. **아래 모든 권한 표의 인증 층 `차단` 판정과 모든 이탈 표의 `HTTP 401` 은 그 두 분기가 거짓일 때의 값이다.** 이 창의 관측으로는 `isPublic` 을 세팅하는 데코레이터가 저장소 전체에 0건이고 `isDevSwaggerRequest` 는 `NODE_ENV === 'dev'` 와 `referer` 와 `x-forwarded-for` 셋을 함께 요구한다 — 그래서 지금 도달 가능한 분기는 없다고 본다. **부재 증명이 아니다** — 검색어는 `isPublic` 하나이고 배포 환경변수는 대조하지 않았다. 코드 수정은 이 창 범위 밖이며 backlog `50m3-admin-jwt-guard-bypass-branches` 로 이월했다.

## 흐름 목록

| flow id | 목적 | actor | status |
|---|---|---|---|
| authority-group-create | 권한 그룹을 만들고 수정하고 삭제한다 | 관리자 | active-prod |
| authority-menu-distribution | 그룹에 메뉴를 배분하고 관리자 계정에 그룹을 붙인다 | 관리자 | active-prod |
| authority-api-exposure | 화면에서 사라진 기능의 API 가 실제로 막히는지 확인한다 | 관리자 | active-prod |
| authority-session-propagation | 권한 변경이 이미 열린 세션에 언제 반영되는지 확인한다 | 관리자 | active-prod |

## 흐름

### authority-group-create

**전제**

- 운영사 관리자로 통합 콘솔에 로그인되어 있다 (`admin.role === 'admin'`)
- 권한 관리 화면은 목록 `admin-security-authority` 와 등록 `admin-security-authority-add` 둘이다
- 배분할 메뉴 코드 집합을 미리 정해 두었다

**절차**

1. `admin-security-authority` 에서 기존 그룹 목록을 확인한다 (`GET /admin/authority`, `authority-adimin.controller.ts:39`).
2. `admin-security-authority-add` 로 이동해 그룹 이름을 입력한다.
3. 저장하면 `POST /admin/authority` 가 트랜잭션 안에서 `tn_auth` 행을 만들고 고른 코드에 대응하는 `tn_menu_auth_map` 행을 함께 넣는다 (`authority.repository.ts:32,47`).
4. 수정은 `POST /admin/authority/edit/:athId` 이고 매핑을 **전부 지운 뒤 다시 넣는다** (`authority.repository.ts:71,82`).
5. 삭제는 `POST /admin/authority/delete` 다.

**삭제는 매핑을 지우지 않는다.** `authority.repository.ts:136-138` 이 `tn_auth` 만 지우고 `tn_menu_auth_map` 은 건드리지 않는다 — 생성·수정과 달리 트랜잭션도 없다. 남은 매핑 행이 무엇이 되는지는 DB 외래키 제약에 달려 있고 **그 제약의 실재를 이 창에서 대조하지 않았다.** 확인하지 않은 것을 안전하다고 적지 않는다.

**권한**

| 층 | 차단성 | 이 흐름에서의 강제 지점 | 실패 시 |
|---|---|---|---|
| 인증 | 차단 | `admin/jwt.guard.ts:26` — 컨트롤러 전체에 `AdminJwtAuthGuard` (`authority-adimin.controller.ts:29`) | HTTP 401 |
| 계정 종류 | 차단 | `roles.guard.ts:27` — `@Roles('admin')` 단독. `sales` 와 `corp` 는 여기서 잘린다 (`authority-adimin.controller.ts:30`) | HTTP 403 접근 권한이 없습니다 |
| 쓰기 스코프 | 차단 | 해당 없음 — 이 컨트롤러에 `CorpReadonlyGuard` 가 걸려 있지 않다. `role` 이 `admin` 이라 걸렸어도 통과한다 (`corp-readonly.guard.ts:21`) | 해당 없음 |
| 기업 노출 등급 | 차단 | 해당 없음 — `CorpAnalysisViewGuard` 미적용 컨트롤러다 (`corp-analysis-view.guard.ts:34`) | 해당 없음 |
| 메뉴 노출 | 비차단 | `menu.repository.ts:32-39` — 보안 관리 메뉴 미배분이면 사이드바에서 사라진다 | 사이드바에서 사라짐. `@Roles('admin')` 만 맞으면 API 는 응답한다 |

**이 흐름의 실질 차단은 2층 하나다.** 권한 관리 화면이 메뉴에서 빠져 있어도 `role === 'admin'` 계정이면 `POST /admin/authority` 를 직접 호출해 자기 그룹을 만들 수 있다.

**실패·이탈**

| 지점 | 조건 | 결과 |
|---|---|---|
| 목록 진입 | `sales` · `corp` 계정 | HTTP 403. 콘솔 자체가 다른 라우터라 도달 경로도 없다 |
| 저장 | 고른 코드가 `tn_menu` 에 없다 | 계속 대응 행이 조회되지 않아 매핑이 조용히 빈다 (`authority.repository.ts:47`) |
| 수정 | 트랜잭션 중단 | 계속 매핑을 지우고 다시 넣는 구조라 롤백에 의존한다 (`authority.repository.ts:71,82`) |
| 삭제 | 매핑 행의 처리 | 계속 `tn_auth` 만 지운다. 남은 매핑의 귀결을 이 문서가 판정하지 않았다 |

### authority-menu-distribution

**전제**

- `authority-group-create` 로 그룹이 서 있다
- 배분 화면은 `admin-security-authority-add` 이고 계정에 그룹을 붙이는 화면은 `admin-user-admin` 이다
- 배분 가능한 메뉴 목록은 `GET /admin/authority/menus` 가 낸다 (`authority-adimin.controller.ts:51`)

**절차**

1. `admin-security-authority-add` 에서 배분할 메뉴 코드를 고른다.
2. 저장하면 고른 코드마다 `tn_menu_auth_map` 행이 들어간다 (`authority.repository.ts:47`).
3. `admin-user-admin` 에서 대상 관리자 계정에 그 그룹(`athId`)을 붙인다.
4. 그 계정이 다음 로그인 때 `GET /admin/menu/my` 로 자기 트리를 받는다. 서버는 `tnMenu` 를 `tnMenuAuthMap` 과 `tnAdmin` 으로 조인해 배분된 메뉴와 **그 부모까지** 낸다 (`menu.repository.ts:34-50`).

**부모가 함께 실리는 것은 규약이다.** 자식만 배분해도 사이드바 계층이 끊기지 않도록 `menu.repository.ts:45-50` 이 `mnuParentId` 를 집합에 더한다. 그래서 배분 집합과 노출 집합은 같지 않다 — 노출 쪽이 항상 같거나 크다.

**배분된 메뉴가 0건이면 빈 배열이다.** `menu.repository.ts:41-43` 이 그 자리에서 조기 반환하며, 그 계정은 로그인은 되고 사이드바가 빈다.

**권한**

| 층 | 차단성 | 이 흐름에서의 강제 지점 | 실패 시 |
|---|---|---|---|
| 인증 | 차단 | `admin/jwt.guard.ts:26` — 배분·조회 양쪽에 `AdminJwtAuthGuard` | HTTP 401 |
| 계정 종류 | 차단 | `roles.guard.ts:27` — 배분은 `@Roles('admin')` 단독이다 (`authority-adimin.controller.ts:30`) | HTTP 403 접근 권한이 없습니다 |
| 쓰기 스코프 | 차단 | 해당 없음 — `CorpReadonlyGuard` 미적용 컨트롤러다 | 해당 없음 |
| 기업 노출 등급 | 차단 | 해당 없음 — `CorpAnalysisViewGuard` 미적용 컨트롤러다 | 해당 없음 |
| 메뉴 노출 | 비차단 | `menu.repository.ts:32-39` — 이 흐름이 **만드는** 것이 바로 이 층의 입력이다 | 사이드바 구성이 바뀔 뿐 API 차단은 바뀌지 않는다 |

**실패·이탈**

| 지점 | 조건 | 결과 |
|---|---|---|
| 메뉴 선택 | 고른 코드가 `tn_menu` 에 없다 | 계속 매핑이 조용히 빈다 |
| 계정 배정 | 대상 계정이 이미 다른 그룹을 갖는다 | 계속 그룹은 계정당 하나(`tnAdmin.athId`)라 덮인다 |
| 트리 조회 | 배분 0건 | 계속 빈 배열을 낸다 (`menu.repository.ts:41-43`) |
| 노출 확인 | 자식만 배분했다 | 계속 부모가 함께 실려 계층이 유지된다 (`menu.repository.ts:45-50`) |

### authority-api-exposure

**전제**

- `authority-menu-distribution` 으로 배분이 끝나 있다
- 확인 대상 화면은 `admin-user-admin` 과 그 등록 화면 `admin-user-admin-add` 다
- 그 화면의 메뉴 코드가 자기 그룹에서 빠져 있다

**절차**

1. 사이드바에서 대상 메뉴가 사라진 것을 확인한다.
2. 그 화면의 URL 을 주소창에 직접 입력한다.
3. 화면이 열리고 API 요청이 나간다.
4. 서버는 `@Roles` 목록만 본다. 목록에 자기 role 이 있으면 **응답한다.**

**메뉴 배분은 서버 인가에 입력되지 않는다.** `menu.repository.ts` 의 조회 결과는 사이드바를 만들 뿐이고 어떤 guard 도 그 결과를 읽지 않는다. 그래서 2~4단계가 성립한다 — 이것이 Q42 와 Q43 의 답이며 두 질문은 같은 사실의 두 얼굴이다.

**화면 안의 버튼·컬럼도 같다.** 권한에 따라 사라지는 것은 클라이언트 상수 필터의 결과이고, 그 버튼이 부르던 API 는 계속 응답한다. **그 필터가 모든 화면에 같은 형태로 있는지는 전수를 세지 않았다** — 세지 않은 것을 전수로 적지 않는다.

**인증조차 없는 메뉴 조회가 하나 있다.** `menu.controller.ts:15` 의 `GET /admin/menu/all` 에는 `@UseGuards` 가 없다. 같은 컨트롤러의 `GET /admin/menu/my` 는 26행에서 `AdminJwtAuthGuard` 를 단다. 이 비대칭은 이 문서의 관측이며 **코드를 고치지 않는다** — 기록만 한다.

**권한**

| 층 | 차단성 | 이 흐름에서의 강제 지점 | 실패 시 |
|---|---|---|---|
| 인증 | 차단 | `admin/jwt.guard.ts:26` — 대부분의 관리자 API 에 걸린다. `GET /admin/menu/all` 은 예외로 걸리지 않는다 (`menu.controller.ts:15`) | HTTP 401 (예외 경로는 401 이 나지 않는다) |
| 계정 종류 | 차단 | `roles.guard.ts:22,27` — `@Roles` 선언이 **없으면 통과시킨다**. 있으면 목록 대조로 막는다 | HTTP 403 접근 권한이 없습니다 |
| 쓰기 스코프 | 차단 | `corp-readonly.guard.ts:24` — 이 흐름의 확인은 GET 이라 항상 통과한다 | 해당 없음 — 이 흐름에서는 발생하지 않는다 |
| 기업 노출 등급 | 차단 | 해당 없음 — `role` 이 `corp` 가 아니면 34행에서 통과한다 | 해당 없음 |
| 메뉴 노출 | 비차단 | `menu.repository.ts:32-39` — 이 흐름이 **비차단임을 실증하는** 층이다 | 사이드바에서 사라짐. URL 직접 입력이 그대로 통한다 |

**실패·이탈**

| 지점 | 조건 | 결과 |
|---|---|---|
| URL 직접 입력 | `@Roles` 에 자기 role 이 있다 | 계속 API 가 응답한다. 메뉴 배분은 입력되지 않는다 |
| URL 직접 입력 | `@Roles` 에 자기 role 이 없다 | HTTP 403 접근 권한이 없습니다 |
| URL 직접 입력 | 핸들러에 `@Roles` 선언 자체가 없다 | 계속 `roles.guard.ts:22` 가 통과시킨다 |
| 메뉴 전체 조회 | `GET /admin/menu/all` | 계속 인증 없이 응답한다 (`menu.controller.ts:15`). 이 문서의 관측이며 수정 대상이 아니다 |

### authority-session-propagation

**전제**

- 대상 관리자가 콘솔에 로그인된 상태로 세션을 유지하고 있다
- 관리자 A 가 `admin-user-admin` 에서 그 계정의 권한 그룹을 바꾼다
- 재진입 지점은 `admin-manage-login` 이다

**절차**

1. A 가 `admin-user-admin` 에서 대상 계정의 `athId` 를 다른 그룹으로 바꾼다.
2. 대상 관리자의 브라우저는 아무 일도 하지 않는다. `_aaT` 쿠키는 그대로 유효하다.
3. 대상 관리자가 새 요청을 보낸다. `admin/jwt.guard.ts:26` 이 쿠키를 검증하고 통과시킨다.
4. 사이드바는 다음 로그인 때 갱신된다 — `GET /admin/menu/my` 를 다시 부르는 시점이 로그인 직후이기 때문이다 (`menu.controller.ts:25`).

**반영은 즉시가 아니다.** 3단계에서 서버가 보는 것은 토큰이 담은 identity 이고, 4단계의 트리 조회만이 DB 의 새 배분을 읽는다. 그래서 **재로그인 전까지 사이드바는 이전 그대로다.**

**그러나 API 차단은 원래 배분을 보지 않는다.** `authority-api-exposure` 가 보인 대로 서버 인가는 `@Roles` 와 토큰의 `role` 만 읽는다 — 그룹을 바꿔도 `role` 이 그대로면 **API 접근 범위는 변하지 않는다.** 즉 이 흐름에서 지연되는 것은 표시이고, 애초에 지연될 차단이 없다.

**세션을 강제로 끊는 경로를 이 창에서 확인하지 못했다.** 그것이 있다면 3단계가 달라지지만, 없다고 단정하지 않는다 — 찾지 못했다는 사실만 적는다.

**권한**

| 층 | 차단성 | 이 흐름에서의 강제 지점 | 실패 시 |
|---|---|---|---|
| 인증 | 차단 | `admin/jwt.guard.ts:26` — 기존 `_aaT` 쿠키가 그대로 유효하다 | HTTP 401 (쿠키 만료 시에만) |
| 계정 종류 | 차단 | `roles.guard.ts:27` — 토큰의 `role` 을 본다. 그룹 변경은 이 값을 바꾸지 않는다 | HTTP 403 접근 권한이 없습니다 |
| 쓰기 스코프 | 차단 | 해당 없음 — 운영사 관리자 축이라 `corpScope` 가 `ORG` 가 아니다 | 해당 없음 |
| 기업 노출 등급 | 차단 | 해당 없음 — `role` 이 `corp` 가 아니다 | 해당 없음 |
| 메뉴 노출 | 비차단 | `menu.repository.ts:32-39` — 다음 로그인의 트리 조회에서만 새 배분을 읽는다 | 사이드바가 이전 그대로 남는다. API 범위는 변하지 않는다 |

**실패·이탈**

| 지점 | 조건 | 결과 |
|---|---|---|
| 그룹 변경 직후 | 대상 관리자가 로그인 상태 유지 | 계속 사이드바가 갱신되지 않는다. 재로그인이 필요하다 |
| 그룹 변경 직후 | 대상 관리자가 API 를 직접 부른다 | 계속 `@Roles` 와 토큰 `role` 만 보므로 범위가 변하지 않는다 |
| 재로그인 | 새 배분이 0건 | 계속 빈 사이드바로 진입한다 (`menu.repository.ts:41-43`) |
| 강제 종료 시도 | 세션 무효화 경로 | 계속 이 창에서 그 경로를 확인하지 못했다 |

## 연혁

릴리스 태그가 존재하지 않는다. 릴리스 열은 `—` 로 두고 커밋 SHA 로 추적한다. 절차의 단계·권한 층·이탈 경로가 바뀐 시점만 적는다.

| 릴리스 | 날짜 | SHA | 변경 요약 |
|---|---|---|---|
| — | 2026-01-19 | `cc8feb03` | 기업관리자 로그인과 API 권한 추가 — `@Roles` 어휘에 `corp` 가 들어왔다 |
| — | 2026-07-01 | `f0ce46c8` | 조직트리와 조직관리자 권한위임 — 같은 role 안에 쓰기 스코프 축이 생겼다 |
| — | 2026-07-01 | `c8419274` | 기업관리자 4그룹 메뉴와 조회·수정 게이팅 — 메뉴 배분이 콘솔별로 갈렸다 |
| — | 2026-07-31 | `6276347a` | 조직관리자 로그인 기업 식별 기준 변경 — 착지 판정의 입력이 바뀌었다 |
