---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-23T00:00:00Z
workstream: W1-거버넌스/RBAC
concerns:
  - "PermissionService / PermissionGate 의 신규 메서드 시그니처는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2)."
  - "comment/review 컨트롤러에 PermissionGate(또는 UserPermissionsMapper) 신규 의존이 추가된다 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException."
  - "신규 매퍼 SQL(UserPermissionsMapper / PermissionsMapper / UsersMapper.epoch)은 DB-방언 계약(L2) 대상 — camelCase alias 는 반드시 큰따옴표(AS \"permissionKey\")로 감싼다(SQL alias 케이스 폴딩 함정, verification-strategies §33). dev DB contract 미구축은 기존 open item."
  - "users 테이블 스키마 변경(role 값집합 확장 + permissions_epoch 컬럼)은 비권위 복원본(db/schema.sql:27) 대상 — 실제 운영 DB 와 대조(pg_dump) 전까지 DDL 의 컬럼 타입은 추론값. 부트스트랩 seed UPDATE 는 운영 maintenance 절차로 분리."
concerns_checked: true
self_verification:
  checklist_passed: true
references:
  requirements: .atp/work-session/20260622-180054/research/W1-requirements.md
  research: null
  adrs:
    - docs/work-log/2026-06-17-jam-platform-roadmap.md
    - docs/work-log/2026-06-17-w3-feature-skeletons.md
    - docs/development/verification-strategies.md
---

# 설계: W1 — 거버넌스 / RBAC (관리자 콘솔 + 권한 게이트 인터셉터 + 세션 권한 전파)

## 목표 / 비목표

### 목표 (FR/NFR 추적)
- **G1 부트스트랩** (FR-12): 최초 ADMIN 을 DB seed/수동 승격으로 지정. 코드 자동 승격 경로 0.
- **G2 임명** (FR-4): ADMIN 이 USER 를 SUBADMIN 으로 승격.
- **G3 권한 토글 부여** (FR-5): ADMIN 이 SUBADMIN 에게 개별 permission 부여.
- **G4 인터셉터** (FR-9, FR-10, FR-11): `HandlerInterceptor` + `addInterceptors` 로 보호 경로 권한 검사.
- **G5 권한 회수** (FR-5 역방향): 부여한 permission 회수 — 즉시 반영.
- **G6 강등/해임** (FR-6): SUBADMIN → USER, 전 권한 일괄 회수.
- **G7 세션 권한 전파** (FR-14, NFR-보안 세션 무결성): role/권한 변경 후 후속 요청에 즉시 반영. 부여·회수 **대칭**.
- **FR-13 흡수**: comment/review 의 `ROLE_ADMIN.equals(role)` 를 (ADMIN 암묵전권 OR SUBADMIN+`CONTENT_MODERATE`) 게이트로 재정의. ADMIN 통과 동작 회귀 0 보존.
- **카탈로그 동기화 계약** (결정2 난제): 코드 권한 키 상수 ↔ DB `permissions` 행을 부팅 시 검증·시드해 오타·불일치 방어.
- **NFR**: 상태변경 CSRF 전수, `#{}` 바인딩(`${}` 금지), 세션 고정 방어(`changeSessionId`) 보존, 감사 로그 대칭, 비파괴 마이그레이션.

### 비목표 (스코프 밖 — 게이트 인프라만 제공)
- 게임잼관리 액션 본체(W2) — `GAME_JAM_MANAGE` 권한 키만 카탈로그에 등록, 보호 경로 등록은 W2 가 수행.
- 포스팅 작성 기능 본체(W3-3) — `POST_WRITE` 권한 키만 등록, 경로 등록은 W3-3 이 수행.
- 심사위원 역할(W2-2), 리뷰어/기술자 배지(W4) — 본 설계는 키 추가 흡수 가능성만 열어둠.
- 권한 변경 이력 열람 UI, 일괄 작업, 사용자 검색 고도화(후속).
- Spring Session 도입 / 세션 저장소 외부화 — 본 설계는 톰캣 in-memory 세션 제약 하에서 회수 즉시성을 달성(아래 §세션 무효화 메커니즘).

---

## 개요

bibimbap 는 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation mapper(`@Mapper` + `#{}`) + JSP 스택이다. 현재 권한은 `users.role` 단일 varchar(30) 와 세션 `role` attr(`UserController:508`)로만 표현되며, comment/review 모더레이션은 `ROLE_ADMIN.equals(role)` 하드코딩이고(`GameCommentController:201`, `GameReviewController:419`), 인터셉터·관리자 콘솔은 전무하다.

본 설계는 **role(ADMIN/SUBADMIN/USER) + user_permissions join** 모델(결정1)을 도입한다. ADMIN 은 전 권한 암묵 보유, SUBADMIN 은 join 에 있는 키만, USER 는 0. 권한 카탈로그는 **DB `permissions` 테이블**(결정2)로 두되, 코드 상수(`PermissionKeys`)와 부팅 시 동기화 검증 계약으로 오타를 방어한다.

가장 까다로운 두 난제를 다음과 같이 확정한다.

- **난제1 (결정2 — 코드↔DB 동기화)**: 단일 정의처는 **코드의 `PermissionKeys` enum 상수**다. 부팅 시 `PermissionCatalogVerifier`(`ApplicationRunner`)가 각 enum 키를 DB `permissions` 에 멱등 시드(UPSERT)하고, 카탈로그에서 enum 에 없는 활성 키가 발견되면 경고 로그를 남긴다. 권한 판정 코드는 항상 enum 을 사용하므로 런타임 오타가 컴파일 단계에서 차단된다.
- **난제2 (결정4 — 타 사용자 세션 회수 즉시성)**: Spring Session/SessionRegistry 가 없어 **타 사용자의 HttpSession 객체에 직접 접근할 표준 경로가 없다**(코드 사실: pom.xml 에 spring-session 의존 없음, in-memory 톰캣 세션). 따라서 "타 세션을 직접 무효화"하지 않고 **`users.permissions_epoch` 버전 스탬프 + 요청당 단일 PK 조회 대조**로 회수 즉시성을 보장한다. 권한을 변경하는 모든 콘솔 액션은 대상 사용자의 epoch 를 +1 한다. 게이트는 세션에 캐시된 (권한 집합 + epoch) 를 쓰되, **요청당 1회 `getPermissionsEpoch(userId)`(PK 단일 인덱스 조회, 권한 join 전체 스캔 아님)로 세션 epoch 와 DB epoch 를 대조**한다. 불일치 시 그 자리에서 권한 재로딩 → 세션 갱신 → 새 권한으로 판정. 결과적으로 **부여·회수 모두 다음 요청에서 즉시 반영**(AC-2 충족)되며, 매 요청 권한 전체 조회보다 싸다(epoch 만 조회, 변경 없으면 권한 join 미조회).

---

## 핵심 결정 요약 (전제 — 재논의 금지, 난제는 본 설계가 확정)

| 결정 | 확정값 | 본 설계의 구체화 |
|---|---|---|
| 결정1 권한모델 | role + user_permissions join | `users.role` 값집합 확장 + `user_permissions(user_id, permission_key)` |
| 결정2 카탈로그 | DB `permissions` 테이블 | **단일 정의처 = 코드 `PermissionKeys` enum**. 부팅 시 `PermissionCatalogVerifier` 가 DB 멱등 시드+검증 |
| 결정3 ADMIN 흡수 | 권한 게이트로 통일 | comment/review `isOperator` → `PermissionGate.canModerate(session)` (ADMIN OR SUBADMIN+CONTENT_MODERATE) |
| 결정4 세션 전파 | 세션 캐시 + 변경 시 무효화 | **`users.permissions_epoch` 스탬프 + 요청당 PK epoch 대조**. mismatch 시 세션 권한 재로딩 |
| 부트스트랩 | DB seed/수동 승격 | `db/bootstrap-admin.sql` 템플릿 + maintenance 절차 문서 |
| 인터셉터 | W1 포함, 보호범위 콘솔/잼관리/포스팅 | URL 패턴 매핑(콘솔 ADMIN) + 게이트 헬퍼(잼관리/포스팅은 W2/W3-3 이 경로 등록) |

---

## 데이터 모델 (DDL)

> 권위 수준 주의(concern 4): `users` 는 `db/schema.sql:27` 의 **비권위 복원본**. 아래 DDL 은 멱등(`IF NOT EXISTS`/`DO $$ guard`)으로 작성해 `db/apply-local-ddl.sh` 로 실행 DB 에 비파괴 적용한다. 타입은 기존 스타일(`bigint`/`varchar`/`timestamptz`)을 따른다.

### 신규 파일: `docs/rbac-ddl.sql` (권위 DDL — apply-local-ddl.sh 가 docs/*-ddl.sql 글롭으로 자동 적용)

```sql
-- W1 거버넌스/RBAC. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
-- 기존 데이터(전원 role='USER') 호환 — 추가만, 파괴 없음.

-- 1) users.role 값집합 확장 (컬럼 신규 아님 — 값 집합만 ADMIN/SUBADMIN/USER 로 확장)
--    기존 varchar(30) DEFAULT 'USER' 유지. CHECK 제약을 멱등 추가해 오타 role 차단.
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_role_check') THEN
        ALTER TABLE "users"
            ADD CONSTRAINT "users_role_check"
            CHECK ("role" IN ('ADMIN', 'SUBADMIN', 'USER'));
    END IF;
END
$$;

-- 2) users.permissions_epoch (결정4 — 세션 권한 전파 버전 스탬프)
--    회수/부여/임명/강등 시 +1. 인터셉터가 세션 캐시 epoch 와 PK 단일조회로 대조.
ALTER TABLE "users"
    ADD COLUMN IF NOT EXISTS "permissions_epoch" bigint DEFAULT 0 NOT NULL;
COMMENT ON COLUMN "users"."permissions_epoch" IS
    'RBAC 권한 변경 버전. 변경 시 +1 → 세션 캐시 epoch 와 mismatch 시 권한 재로딩(회수 즉시성, 결정4)';

-- 3) permissions (권한 카탈로그 — 결정2. 코드 PermissionKeys enum 이 단일 정의처, DB 는 멱등 시드)
CREATE SEQUENCE IF NOT EXISTS "permissions_id_seq";
CREATE TABLE IF NOT EXISTS "permissions" (
    "id"             bigint DEFAULT nextval('permissions_id_seq'::regclass) NOT NULL,
    "permission_key" character varying(50) NOT NULL,
    "display_name"   character varying(100) NOT NULL,
    "is_active"      boolean DEFAULT true NOT NULL,
    "created_at"     timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "permissions_id_seq" OWNED BY "permissions"."id";
CREATE UNIQUE INDEX IF NOT EXISTS "ux_permissions_key"
    ON "permissions" ("permission_key");

-- 4) user_permissions (SUBADMIN 개별 권한 join — 결정1)
CREATE SEQUENCE IF NOT EXISTS "user_permissions_id_seq";
CREATE TABLE IF NOT EXISTS "user_permissions" (
    "id"             bigint DEFAULT nextval('user_permissions_id_seq'::regclass) NOT NULL,
    "user_id"        bigint NOT NULL,
    "permission_key" character varying(50) NOT NULL,
    "granted_by"     bigint,
    "created_at"     timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "user_permissions_id_seq" OWNED BY "user_permissions"."id";

DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_permissions_user_id_fkey') THEN
        ALTER TABLE "user_permissions"
            ADD CONSTRAINT "user_permissions_user_id_fkey"
            FOREIGN KEY ("user_id") REFERENCES "users" ("id");
    END IF;
END
$$;
-- 같은 사용자에 같은 키 중복 부여 방지(토글 멱등성 보장)
CREATE UNIQUE INDEX IF NOT EXISTS "ux_user_permissions_user_key"
    ON "user_permissions" ("user_id", "permission_key");
CREATE INDEX IF NOT EXISTS "idx_user_permissions_user"
    ON "user_permissions" ("user_id");

-- 5) rbac_audit_log (NFR-감사로그 — 임명/강등/토글 대칭 기록)
CREATE SEQUENCE IF NOT EXISTS "rbac_audit_log_id_seq";
CREATE TABLE IF NOT EXISTS "rbac_audit_log" (
    "id"            bigint DEFAULT nextval('rbac_audit_log_id_seq'::regclass) NOT NULL,
    "actor_id"      bigint NOT NULL,        -- 변경 수행 ADMIN
    "target_id"     bigint NOT NULL,        -- 변경 대상 사용자
    "action"        character varying(30) NOT NULL,  -- APPOINT/DEMOTE/GRANT/REVOKE
    "permission_key" character varying(50),          -- GRANT/REVOKE 시만
    "created_at"    timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "rbac_audit_log_id_seq" OWNED BY "rbac_audit_log"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'rbac_audit_log_action_check') THEN
        ALTER TABLE "rbac_audit_log"
            ADD CONSTRAINT "rbac_audit_log_action_check"
            CHECK ("action" IN ('APPOINT', 'DEMOTE', 'GRANT', 'REVOKE'));
    END IF;
END
$$;
```

### `db/schema.sql` 반영 (dev/live 듀얼 스키마, 최초 기동 시 1회 자동 주입)
- `db/schema.sql` 의 `users` 블록 뒤(`ALTER SEQUENCE "users_id_seq" OWNED BY ...` 직후)에 위 1·2번(role CHECK + permissions_epoch)을 추가.
- `recruit_posts` 블록 뒤에 3·4·5번(permissions / user_permissions / rbac_audit_log)을 신설 블록으로 추가.
- 반영 방식은 `game_reviews`(권위 DDL)가 schema.sql 에 동기화된 선례(schema.sql:115~232)와 동일 — **docs/rbac-ddl.sql 이 권위, schema.sql 은 그 사본**.

### 부트스트랩 ADMIN seed (G1 / FR-12)
- 신규 파일 `db/bootstrap-admin.sql` (수동 maintenance 전용 — apply-local-ddl.sh 글롭 `docs/*-ddl.sql` 에 안 걸림. 자동 적용 금지가 의도):
```sql
-- 최초 ADMIN 지정. 운영자가 대상 user 를 식별해 1회 수동 실행.
-- 자동 승격 경로 부재(FR-12, AC-6) — 코드/설정 노출 0.
UPDATE "users" SET "role" = 'ADMIN', "permissions_epoch" = "permissions_epoch" + 1
WHERE "canonical_email" = :'admin_email' AND "is_delete" IS NOT TRUE;
```
- maintenance 절차는 `docs/usage/` 또는 `docs/development/` 에 1단락 추가(documentation-advisor 소관 — 본 설계는 seed SQL 과 절차 요구만 명시).

---

## 외부 계약 (API)

> 공통: 모든 상태변경은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`). 모든 콘솔 API 는 인터셉터에서 ADMIN 게이트 통과 후 도달. 응답은 기존 컨트롤러 패턴(`Map<String,Object>` + `status`/`message`)을 따른다.

### 401 vs 403 정책 (확정)
- **미인증**(세션에 `userId` 없음): API 엔드포인트는 **401**(JSON `{status:401, message:"로그인이 필요합니다."}`), 콘솔 **페이지** 진입은 `redirect:/login`.
- **인증·미인가**(로그인됐으나 권한 없음): **403** (JSON `{status:403, message:"권한이 없습니다."}`), 페이지도 403(리다이렉트 아님 — 권한 없음을 로그인으로 오인 유도 방지).
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.

### 콘솔 페이지 (뷰)
| method | path | 권한 | 응답 |
|---|---|---|---|
| GET | `/admin/console` | ADMIN | `admin-console` JSP. 운영진 목록(FR-7) + 카탈로그 + CSRF 토큰 모델 주입 |

### 콘솔 4액션 (상태변경 API — 전부 CSRF + ADMIN 게이트)
| 액션 | method | path | 요청 | 응답(200) | 에러 |
|---|---|---|---|---|---|
| 임명(G2) | POST | `/admin/users/{userId}/appoint` | (path userId) | `{status:200, message, userId, role:"SUBADMIN"}` | 404(대상 없음), 409(이미 ADMIN/SUBADMIN), 403(CSRF/권한) |
| 권한 토글(G3 부여 / G5 회수) | POST | `/admin/users/{userId}/permissions/{permissionKey}/toggle` | (path) | `{status:200, message, userId, permissionKey, granted:true|false}` | 404(대상/키 없음), 422(대상이 SUBADMIN 아님), 403 |
| 강등/해임(G6) | POST | `/admin/users/{userId}/demote` | (path userId) | `{status:200, message, userId, role:"USER", revokedCount:N}` | 404, 422(이미 USER), 403 |
| 운영진 목록(FR-7) | GET | `/admin/operators` | (없음) | `{status:200, operators:[{userId, displayName, role, permissions:[...]}]}` | 403 |

- **토글 단일 엔드포인트 채택 근거**: 부여/회수는 대칭 역연산(결정·요구 FR-5 G3/G5)이므로 같은 path 에서 현재 상태를 토글한다. 응답 `granted` 로 결과 방향 명시. 별도 grant/revoke 2엔드포인트 대비 표면 최소, 대칭 보장.
- **ADMIN 권한 부여 불가**(FR-8): appoint 는 USER→SUBADMIN 만. ADMIN role 부여 API 없음(부트스트랩 전용).
- **공통 부작용**: appoint/toggle/demote 는 모두 (a) `users.permissions_epoch += 1` (대상), (b) `rbac_audit_log` insert. demote 는 추가로 `user_permissions` 전건 삭제.

### 인터셉터 권한 판정 계약
- 입력: `HttpServletRequest`(세션 + 요청 경로). 출력: `boolean`(true=통과, false=거부 후 응답 작성).
- 판정: 보호 경로 → 필요 권한 키 매핑 → `PermissionGate.has(session, request, requiredKey)`.
- 거부 시 응답: API 경로(`/admin/**`, `/api/**`)는 status JSON, 페이지 경로는 401→redirect / 403→상태코드.

---

## 인터셉터 설계 (G4 / QG-1)

### 보호 경로 매핑 방식 (택1 확정: **URL 패턴 + 게이트 헬퍼 병용**)
- **콘솔 경로**(`/admin/**`)는 인터셉터의 `addPathPatterns("/admin/**")` 로 **URL 패턴 매핑** → ADMIN 게이트. 이유: 콘솔 경로 전체가 단일 권한(ADMIN only)이고 W1 에서 경로가 확정되므로 URL 패턴이 가장 단순·명시적.
- **잼관리/포스팅 액션**(W2/W3-3 소비)은 본 W1 에서 경로가 미확정이므로 인터셉터에 등록하지 않는다. 대신 **`PermissionGate` 헬퍼**(`gate.require(session, request, PermissionKeys.GAME_JAM_MANAGE)`)를 제공해 W2/W3-3 컨트롤러가 액션 진입부에서 직접 호출. 어노테이션 방식은 신규 커스텀 어노테이션 + ArgumentResolver/AOP 인프라가 필요해 과도(미채택). 핸들러 메타 방식도 동일 이유로 미채택.
- **결정 근거**: 콘솔=URL패턴(경로 확정·단일권한), 소비 액션=게이트 헬퍼(경로 미확정·호출지점 결합). 두 방식의 권한 판정 코어는 동일 `PermissionGate.has(...)` 로 단일화 → 중복 0.

### 미인증 vs 미인가 응답 정책 (확정 — §외부 계약과 일치)
- 미인증: 페이지 `redirect:/login`, API 401.
- 미인가: 페이지/API 모두 403(리다이렉트 금지).
- 인터셉터는 경로가 `/admin/**` 중 API(`/admin/operators`, `/admin/users/**`)인지 페이지(`/admin/console`)인지로 분기.

### 결정4(세션 캐시 + epoch 무효화)와의 연동
- `PermissionGate.has(session, request, key)` 내부:
  1. 세션 `userId` 없음 → 미인증(false, 401/redirect).
  2. 세션 캐시된 `role`·`permissions`(Set)·`permsEpoch` 읽기.
  3. **요청당 1회** `usersMapper.getPermissionsEpoch(userId)` (PK 단일조회) → `dbEpoch`.
  4. `dbEpoch != sessionPermsEpoch` 면: `role` 재조회 + `userPermissionsMapper.listKeys(userId)` 재로딩 → 세션 갱신(`role`, `permissions`, `permsEpoch=dbEpoch`).
  5. 판정: `role == ADMIN` → true(암묵 전권). `role == SUBADMIN && permissions.contains(key)` → true. else false.
- **회수 즉시성 논증(AC-2)**: ADMIN 이 회수하면 대상 `permissions_epoch += 1`. 대상의 다음 요청에서 3→4 단계가 mismatch 를 감지해 권한을 재로딩하므로 회수가 즉시 반영된다. 세션을 직접 무효화하지 않고도(인프라 부재 우회) 회수 우회가 불가능하다.
- **성능 논증(NFR-성능)**: 변경 없는 정상 요청은 epoch PK 조회 1회만 추가(권한 join 미조회). 변경 직후 1회만 재로딩. 매 요청 전체 권한 조회 대비 저비용.

---

## 시퀀스

### S1. 임명 → 토글(부여) → 회수 → 강등 + 세션 전파
```
[ADMIN 세션] POST /admin/users/42/appoint (CSRF)
  → 인터셉터: PermissionGate ADMIN 통과
  → AdminConsoleController.appoint(42)
      → CSRF 검증
      → usersMapper.getUser(42) 존재·role==USER 확인 (아니면 409/404)
      → usersMapper.updateRole(42, "SUBADMIN")
      → usersMapper.bumpPermissionsEpoch(42)         # epoch +1
      → auditMapper.insert(actor, 42, "APPOINT", null)
      → 200 {role:"SUBADMIN"}

[ADMIN] POST /admin/users/42/permissions/POST_WRITE/toggle (CSRF)
  → AdminConsoleController.togglePermission(42, "POST_WRITE")
      → CSRF + 대상 role==SUBADMIN 확인 (아니면 422)
      → PermissionKeys.isValid("POST_WRITE") 확인 (아니면 404)
      → 현재 보유? userPermissionsMapper.exists(42,"POST_WRITE")
        - 미보유 → insert (granted_by=actor) → granted=true
        - 보유   → delete                    → granted=false  (회수=대칭 역연산)
      → usersMapper.bumpPermissionsEpoch(42)         # 부여·회수 둘 다 +1
      → auditMapper.insert(actor, 42, granted?"GRANT":"REVOKE", "POST_WRITE")
      → 200 {granted}

[사용자 42] (다음 요청) GET 포스팅 작성 액션
  → W3-3 컨트롤러 진입부: gate.require(session, request, POST_WRITE)
      → epoch mismatch 감지(이전 단계 bump) → 권한 재로딩
      → 부여 상태면 통과 / 회수 상태면 403  ← AC-1 / AC-2

[ADMIN] POST /admin/users/42/demote (CSRF)
  → AdminConsoleController.demote(42)
      → CSRF + role==SUBADMIN 확인 (아니면 422)
      → userPermissionsMapper.deleteAllByUser(42)    # 전 권한 회수(G6)
      → usersMapper.updateRole(42, "USER")
      → usersMapper.bumpPermissionsEpoch(42)
      → auditMapper.insert(actor, 42, "DEMOTE", null)
      → 200 {role:"USER", revokedCount:N}            ← AC-3
```

### S2. comment/review 모더레이션 흡수 후 판정 (FR-13)
```
[사용자 X 세션] DELETE /game/1/comments/5 (CSRF)
  → GameCommentController.deleteComment
      → CSRF 검증 (기존 보존)
      → userId = sessionUserId(session)
      → canModify(userId, comment.userId, session):
          작성자 본인? → true
          else → permissionGate.canModerate(session, request):
                   epoch 대조 후 (role==ADMIN) OR (SUBADMIN && perms.contains(CONTENT_MODERATE))
      → 통과 시 삭제, 아니면 403
```
- **ADMIN 회귀 보존 논증(AC-7)**: 흡수 후에도 `role==ADMIN` 분기가 `canModerate` 첫 조건이라 기존 ADMIN 통과 동작은 동일하게 유지된다. 기존 테스트 `deleteCommentByOperatorSucceeds`(loginSession 99L/"ADMIN") · `deleteReviewByOperatorSucceeds`(99L/"ADMIN")는 세션 role==ADMIN 이므로 epoch 대조에서 mismatch 가 없으면(테스트 세션 epoch 미설정 시 0==0 정합) ADMIN 분기로 통과. 신규로 SUBADMIN+CONTENT_MODERATE 통과 케이스만 추가된다.

---

## 파일 영향 맵

> 소유권 분할 가이드(implementation-advisor 용 worker 단위 후보):
> **U-SCHEMA**(DDL/seed) · **U-DOMAIN**(enum/data/mapper/catalog verifier) · **U-GATE**(PermissionGate/Interceptor/config) · **U-CONSOLE**(콘솔 컨트롤러+JSP) · **U-ABSORB**(comment/review 흡수) · **U-SESSION**(로그인 세션 권한 스냅샷).
> 의존: U-SCHEMA → U-DOMAIN → {U-GATE, U-CONSOLE, U-ABSORB, U-SESSION}. U-GATE 는 U-CONSOLE/U-ABSORB/W2·W3-3 소비의 공통 선행.

| 변경 유형 | 경로 | 역할 | 소유 |
|---|---|---|---|
| 신규 | `docs/rbac-ddl.sql` | 권위 DDL(permissions/user_permissions/rbac_audit_log/role CHECK/epoch). apply-local-ddl.sh 자동 적용 | U-SCHEMA |
| 신규 | `db/bootstrap-admin.sql` | 최초 ADMIN seed 템플릿(수동 전용, 자동적용 제외 글롭 밖) | U-SCHEMA |
| 수정 | `db/schema.sql` | users 블록에 role CHECK+epoch, 신규 3테이블 블록 추가(rbac-ddl 사본) | U-SCHEMA |
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/PermissionKeys.java` | 권한 키 enum **단일 정의처**(GAME_JAM_MANAGE/POST_WRITE/CONTENT_MODERATE + displayName) + `isValid(String)` | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/Roles.java` | role 상수(ADMIN/SUBADMIN/USER). comment/review/UserController 중복 상수 단일화 대체 | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/config/PermissionCatalogVerifier.java` | `ApplicationRunner` — 부팅 시 enum→DB permissions 멱등 시드 + 불일치 경고(난제1 동기화 계약) | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/PermissionData.java` | permissions 행 POJO(permissionKey/displayName/isActive) | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/OperatorView.java` | 운영진 목록 행(userId/displayName/role/permissionKeys) | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/PermissionsMapper.java` | `@Mapper` 카탈로그 시드/조회(`#{}`) | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/UserPermissionsMapper.java` | `@Mapper` user_permissions CRUD(listKeys/exists/insert/delete/deleteAllByUser, `#{}`) | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/RbacAuditMapper.java` | `@Mapper` 감사 로그 insert(`#{}`) | U-DOMAIN |
| 수정 | `src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java` | `getPermissionsEpoch(userId)`·`bumpPermissionsEpoch(userId)`·`updateRole(userId, role)`·운영진 목록 조회 추가(`#{}`, camelCase alias 큰따옴표) | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/PermissionGate.java` | 권한 판정 코어 — `has(session, request, key)`/`canModerate(session, request)`/`require(...)` + epoch 대조·재로딩(결정4) | U-GATE |
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/RbacInterceptor.java` | `HandlerInterceptor` — `/admin/**` ADMIN 게이트, 401/403 분기 | U-GATE |
| 신규 | `src/main/java/com/pandoli365/bibimbap/config/InterceptorConfig.java` | `WebMvcConfigurer.addInterceptors` 로 RbacInterceptor 등록(`/admin/**`) | U-GATE |
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/AdminConsoleController.java` | 콘솔 페이지(GET /admin/console) + 4액션 API(appoint/toggle/demote/operators) | U-CONSOLE |
| 신규 | `src/main/webapp/WEB-INF/views/admin-console.jsp` | 운영진 목록·임명·토글·강등 폼(CSRF hidden, 표시용 — 게이트 아님) | U-CONSOLE |
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java` | `isOperator(role)` → `permissionGate.canModerate(session, request)` 흡수(FR-13). PermissionGate 의존 주입 | U-ABSORB |
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java` | 동일 흡수(FR-13). PermissionGate 의존 주입 | U-ABSORB |
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java` | `saveLoginSession` 에 권한 스냅샷 추가: `permissions`(Set), `permsEpoch`(user.permissions_epoch). role attr 보존 | U-SESSION |
| 수정 | `src/main/java/com/pandoli365/bibimbap/data/UserData.java` | `permissionsEpoch` 필드 + getter/setter 추가(매퍼 alias 매핑용) | U-SESSION |
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 매퍼·PermissionGate `@MockBean` 등록(contextLoads 보존 — verification §30) | (검증) |
| 신규 | `src/test/.../AdminConsoleControllerTest.java` | 4액션 + 401/403/CSRF/422 단위 테스트 | (검증) |
| 신규 | `src/test/.../PermissionGateTest.java` | epoch 대조·재로딩·ADMIN/SUBADMIN/USER 판정·회수 즉시성 단위 | (검증) |
| 수정 | `src/test/.../GameCommentControllerTest.java` | 흡수 회귀: ADMIN 통과 보존 + SUBADMIN+CONTENT_MODERATE 통과 케이스 추가 | (검증) |
| 수정 | `src/test/.../GameReviewControllerTest.java` | 동일 흡수 회귀 | (검증) |

> SSR 호출지점 전수 확인(verification-strategies §영향맵 SSR 포함): `UserData.permissionsEpoch` 추가는 신규 필드라 기존 호출지점 깨짐 0. role 세션 attr 의 SSR 소비처(JSP `${role}`)는 표시용이며 게이트 아님(NFR-인가경계) — 동작 불변.

### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
```java
// PermissionGate — 권한 판정 코어. session+request 만으로 epoch 대조·판정 가능(최소).
boolean has(HttpSession session,        // userId/캐시된 role·permissions·permsEpoch 출처
            HttpServletRequest request, // (미사용 후보 — concern: 응답작성/경로분기 불요하면 제거)
            String permissionKey)       // 통과에 필요한 권한 키(PermissionKeys 값)
boolean canModerate(HttpSession session,        // 동일 세션 출처
                    HttpServletRequest request) // (미사용 후보 — concern 동일)
// require: 미인가 시 예외/거부 신호. 호출자(W2/W3-3)가 응답 매핑.
boolean require(HttpSession session, String permissionKey)  // 호출지점 결합 게이트

// UsersMapper 추가 (@Mapper, #{} only, camelCase alias 는 "AS \"...\"")
long getPermissionsEpoch(long userId)          // 요청당 PK 단일조회(결정4 대조 좌변)
int  bumpPermissionsEpoch(long userId)         // 변경 시 +1(부여·회수·임명·강등 공통)
int  updateRole(long userId, String role)      // 임명/강등 role 전이
List<OperatorView> listOperators()             // FR-7 운영진 목록(role IN (ADMIN,SUBADMIN))

// UserPermissionsMapper (@Mapper, #{} only)
List<String> listKeys(long userId)             // 세션 권한 재로딩 소스
boolean exists(long userId, String permissionKey)  // 토글 방향 결정
int insert(long userId, String permissionKey, long grantedBy)  // 부여
int delete(long userId, String permissionKey)  // 회수
int deleteAllByUser(long userId)               // 강등 시 전건 회수
```
> ⚠️ inflate 마킹(concern 1): `PermissionGate.has`/`canModerate` 의 `request` 파라미터는 "거부 응답을 게이트가 직접 작성하는가, 아니면 호출자가 작성하는가"에 따라 사용 여부가 갈린다. 본 설계는 **거부 응답을 호출자(인터셉터/컨트롤러)가 작성**하고 게이트는 boolean 만 반환하도록 권장 → 그 경우 `request` 는 제거 대상. 구현 1보에서 boolean-only 로 시작하고, 게이트가 응답을 직접 써야 할 필요가 확정되면 그때 `request` 추가(최소 인자 원칙).

---

## 대안 비교

| 주제 | 안 | 장점 | 단점 | 채택 |
|---|---|---|---|---|
| 세션 회수 즉시성 | (A) epoch 스탬프 + 요청당 PK 대조 | in-memory 세션 제약 우회, 회수 즉시, 저비용 | users 컬럼 1개 추가 | **채택** |
| | (B) Spring Session + SessionRegistry 로 타 세션 직접 invalidate | 표준적 | 신규 의존·세션 저장소 외부화·WAR 설정 대공사, W1 스코프 초과 | 기각 |
| | (C) 매 요청 권한 전체 DB 조회 | 단순 | 모든 요청에 join 스캔(성능), 캐시 이점 0 | 기각 |
| 카탈로그 정의처 | (A) 코드 enum 단일 + DB 멱등 시드/검증 | 컴파일 타임 오타 차단, 운영 카탈로그 가시성 | 부팅 verifier 1개 | **채택** |
| | (B) DB 단독(코드 문자열) | 운영자 즉시 추가 | 권한 키 오타 런타임까지 잠복(난제) | 기각 |
| 권한 토글 API | (A) 단일 toggle 엔드포인트 | 부여·회수 대칭 보장, 표면 최소 | 현재 상태 조회 1회 | **채택** |
| | (B) grant/revoke 2엔드포인트 | 의도 명시적 | 대칭 책임 분산, 경로 2배 | 기각 |
| 보호 경로 매핑 | (A) 콘솔=URL패턴 + 소비=게이트 헬퍼 | 경로 확정/미확정 각각 최적, 코어 단일 | 두 진입 방식 | **채택** |
| | (B) 커스텀 어노테이션 + AOP | 선언적 | 신규 인프라 과도, W1 2~3 경로엔 오버엔지니어링 | 기각 |

---

## 롤아웃 / 마이그레이션

### 순서 (NFR-운영)
1. **스키마 적용**: `docs/rbac-ddl.sql` → `db/apply-local-ddl.sh` (로컬) / 운영은 동일 멱등 DDL 수동 적용. 기존 전원 role='USER' 호환(추가만, 파괴 0). `permissions_epoch` DEFAULT 0 → 기존 사용자 세션 epoch(미설정=0)와 정합.
2. **코드 배포**: PermissionKeys/PermissionGate/Interceptor/콘솔/흡수. 부팅 시 `PermissionCatalogVerifier` 가 permissions 카탈로그 시드.
3. **부트스트랩 ADMIN**: `db/bootstrap-admin.sql` 로 최초 ADMIN 1회 수동 지정(epoch +1 포함). 이후 콘솔 진입 가능.
4. 인터셉터 활성 → 보호 경로 게이트 발효.

### 역호환
- 기존 USER: role/세션 attr 불변, comment/review 작성·본인 수정 동작 불변(canModify 의 작성자 본인 분기 보존).
- 기존 ADMIN(부트스트랩 전): 흡수 후에도 `role==ADMIN` 분기로 모더레이션 통과 보존(AC-7).
- 세션 attr `role` 보존 — 신규 `permissions`/`permsEpoch` attr 추가만. JSP `${role}` 표시 불변.

### 롤백
- 코드 롤백: 흡수 전 `isOperator(role)=ROLE_ADMIN.equals(role)` 로 되돌리면 comment/review 는 ADMIN-only 로 복귀(SUBADMIN 모더레이션만 사라짐). 인터셉터 미등록 시 콘솔 경로는 노출되나 콘솔 컨트롤러 자체가 ADMIN 게이트를 내부에서도 호출하므로 안전.
- 스키마 롤백: 신규 테이블/컬럼은 추가 전용이라 drop 없이 잔존해도 무해(비파괴). 필요 시 명시적 DROP 은 별도 maintenance.

---

## AC 매핑

| AC | 요구 | 만족 설계 요소 | 비고 |
|---|---|---|---|
| AC-1 | 부여한 POST_WRITE 로 포스팅 액션 통과 | toggle 부여 → epoch bump → 대상 다음 요청에서 gate 재로딩 → 통과 | S1 |
| AC-2 | **회수 시 다음 요청부터 403(즉시)** | toggle 회수 → epoch bump → 대상 다음 요청 gate epoch mismatch → 권한 재로딩 → 403. **타 세션 직접 무효화 없이 epoch 대조로 회수 우회 차단** | §인터셉터-결정4 연동 논증, S1 |
| AC-3 | 강등 시 전 권한 회수 + 목록 미표시 | demote: deleteAllByUser + updateRole(USER) + epoch bump → listOperators 는 role IN (ADMIN,SUBADMIN) 만 | S1 |
| AC-4 | 비-ADMIN 콘솔 접근 차단 | RbacInterceptor `/admin/**` ADMIN 게이트 → 403(페이지)/401(미인증 redirect) | §인터셉터 |
| AC-5 | 콘솔 상태변경 CSRF 없으면 403 | 4액션 전부 `CsrfTokens.isValid` 선검증 → 403 + errorBody | §외부계약 공통 |
| AC-6 | 부트스트랩 ADMIN 만 진입, 자동승격 부재 | appoint 는 SUBADMIN 까지만(FR-8), ADMIN 부여 API 없음. 최초 ADMIN=수동 seed | §부트스트랩 |
| AC-7 | **모더레이션: ADMIN+CONTENT_MODERATE 통과 + ADMIN 회귀 보존(W3-2 PASS)** | canModerate 첫 분기 role==ADMIN → 기존 테스트(deleteCommentByOperatorSucceeds/deleteReviewByOperatorSucceeds, 세션 ADMIN) 통과 보존. SUBADMIN+CONTENT_MODERATE 케이스 신규 추가 | S2 논증 |
| AC-8 | 임명/토글/회수/강등 감사 대칭 기록 | rbac_audit_log: APPOINT/GRANT/REVOKE/DEMOTE 4액션 전부 insert | §데이터모델 5 |
| AC-9 | 권한 SQL `${}` 없음 | 신규 매퍼 전부 `#{}` 바인딩만, `${}` 0 | §파일영향맵 매퍼 |

---

## 검증 포인트 (verification-advisor 점검 대상)

> L레벨 매핑(verification-strategies.md): 인증/인가/롤 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias = **L1+L2(dev DB contract)**. 신규 컨트롤러·매퍼 의존 = full `./mvnw -o test` 의무.

### 회귀·게이트 검증 (시나리오)
- **VP-1 (AC-7 회귀, L1)**: `GameCommentControllerTest.deleteCommentByOperatorSucceeds` + `GameReviewControllerTest.deleteReviewByOperatorSucceeds` 가 흡수 후에도 PASS(ADMIN 세션 모더레이션 통과 보존). revert 흡수 시에도 PASS, 흡수 후 PASS — 동작 불변.
- **VP-2 (AC-2 회수 즉시성, L1+L3)**: PermissionGateTest — epoch mismatch 시 권한 재로딩 후 회수된 키가 false. L3 스모크: 부여→토글 회수→대상 다음 요청 403.
- **VP-3 (AC-4 인터셉터 게이트, L1+L3)**: 비-ADMIN `/admin/**` 접근 → 403, 미인증 → 401/redirect.
- **VP-4 (AC-5 CSRF, L1)**: 4액션 CSRF 누락 → 403 + mapper 미호출(기존 `deleteCommentRejectsMissingCsrfBeforeMapperAccess` 패턴 준용).
- **VP-5 (DB-방언 계약, L2)**: 신규 매퍼 반환 Map/POJO 키가 컨트롤러 조회 키와 정합(camelCase alias 큰따옴표 확인) + listOperators 집계가 샘플 데이터와 일치.
- **VP-6 (contextLoads, L1)**: `BibimbapApplicationTests` 에 신규 매퍼·PermissionGate @MockBean 등록 후 PASS(verification §30).

### 집합 전수 체크 AC (design-advisor 집합 전수 패턴 — 시점·표현 self-audit 적용)
> self-audit: 아래 카운트는 모두 **본 설계가 신규 생성하는 정적 산출물**이며 verification 시점까지 본 워크스트림 외 변경 주체가 없다(시점 안정). 표현은 단일 리터럴 grep 취약성을 피해 enum 멤버/CHECK 목록/audit action 처럼 **구조적 불변식**에 앵커한다.

- **AC-T1 권한 카탈로그 전수 3건 정합** — `PermissionKeys` enum 멤버 수 == DB 시드 키 수 == 3 (GAME_JAM_MANAGE/POST_WRITE/CONTENT_MODERATE). 검증: enum 멤버 `grep -c` == 3 AND PermissionCatalogVerifier 시드 대상 == enum 전체(코드상 enum values() 순회이므로 멤버 추가 시 자동 동기 — 불변식). 키 추가/삭제 누락을 갯수 1로 동시 커버.
- **AC-T2 콘솔 상태변경 액션 전수 4건 CSRF 가드** — AdminConsoleController 의 상태변경 핸들러(appoint/toggle/demote 및 추가분) 전수에 `CsrfTokens.isValid` 선검증 존재: `grep -c 'CsrfTokens.isValid' AdminConsoleController.java` == 상태변경 핸들러 수(≥3, GET operators 제외). 핸들러 추가 시 가드 누락을 동시 검출.
- **AC-T3 감사 action 전수 4종 기록** — `rbac_audit_log_action_check` CHECK 의 IN 목록(APPOINT/DEMOTE/GRANT/REVOKE) 4종 == 콘솔 액션이 insert 하는 action 종류 집합. 검증: DDL CHECK IN 항목 4 AND auditMapper insert 호출지점이 4종 전수 사용(임명/강등/부여/회수 대칭, AC-8).
- **AC-T4 epoch bump 전수** — 권한을 변경하는 모든 콘솔 액션(appoint/toggle/demote)이 `bumpPermissionsEpoch` 를 호출: `grep -c 'bumpPermissionsEpoch' AdminConsoleController.java` == 권한변경 핸들러 수. 1건이라도 누락 시 회수 우회 보안결함(AC-2 위반) → FAIL. **이 전수 AC 가 결정4 즉시성의 핵심 가드**.
- **AC-T5 권한 SQL `${}` 0건** — 신규 매퍼 4개(PermissionsMapper/UserPermissionsMapper/RbacAuditMapper/UsersMapper 추가분)에 `${` 매치 0: `grep -rc '\${' <매퍼 4파일>` == 0 (AC-9).

---

## 잔여 오픈 질문
없음(0). 결정1~4 전제 고정, 두 난제(카탈로그 동기화·세션 무효화)는 본 설계가 구체 메커니즘으로 확정. 시그니처 inflate 위험·신규 매퍼 의존 full-test·DB-방언 L2·users 스키마 권위 수준은 오픈 질문이 아니라 **구현 단계 점검 항목**으로 `concerns` 에 이관.
