chore(atp): W1 work-session 산출물 기록 (sid 20260622-180054)

feasibility 판정 → W1 requirements/design/impl 보고서. 설계 세션 전환 결정 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
이정수 2026-06-23 10:38:57 +09:00
parent 941f9fb128
commit a19619f434
5 changed files with 844 additions and 0 deletions

View File

@ -0,0 +1,438 @@
---
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` 에 이관.

View File

@ -0,0 +1,82 @@
---
phase: implementation
agent: implementation-advisor
agent_version: 1
generated_at: 2026-06-23T00:00:00Z
workstream: W1-거버넌스/RBAC
concerns: []
concerns_checked: true
workers_spawned: 22
planned_workers: 24
actual_workers: 22
self_verification:
checklist_passed: true
unused_diagnostics: 0
full_test: "BUILD SUCCESS — 65 tests, 0 failures, 0 errors, 0 skipped"
---
# W1 구현 로그 — 거버넌스/RBAC
## 구현 순서 (설계 의존 준수)
U-SCHEMA → U-DOMAIN → U-GATE(+verifier) → {U-CONSOLE, U-ABSORB, U-SESSION} → 테스트.
의존 있는 파일(PermissionGate 소비 컨트롤러/테스트)은 선행 완료 후 spawn(병렬 충돌 0).
## worker 분할 (1파일 1worker, 소유권 겹침 0)
| worker | 파일 | 유형 | 결과 |
|---|---|---|---|
| w-001 migration-writer | docs/rbac-ddl.sql, db/bootstrap-admin.sql, db/schema.sql | create×2/modify | OK (멱등 DDL, 글롭 분리 검증) |
| w-002 | security/PermissionKeys.java | create | OK (3멤버+isValid) |
| w-003 | security/Roles.java | create | OK |
| w-004 | data/PermissionData.java | create | OK |
| w-005 | data/OperatorView.java | create | OK |
| w-006 | mapper/PermissionsMapper.java | create | OK (alias 큰따옴표, ON CONFLICT) |
| w-007 | mapper/UserPermissionsMapper.java | create | OK (#{} only) |
| w-008 | mapper/RbacAuditMapper.java | create | OK |
| w-009 | mapper/UsersMapper.java | modify | OK (epoch/role/listOperators, alias 큰따옴표) |
| w-010 | config/PermissionCatalogVerifier.java | create | OK (+advisor null-guard 보강) |
| w-011 | security/PermissionGate.java | create | OK (+advisor isAdmin/isAuthenticated 추가) |
| w-012 | security/RbacInterceptor.java | create | OK (401/403/redirect 분기) |
| w-013 | config/InterceptorConfig.java | create | OK (/admin/**) |
| w-014 | controller/AdminConsoleController.java | create | OK (5엔드포인트, CSRF×3, bump×3, audit 4종) |
| w-015 | webapp/.../admin-console.jsp | create | OK (scriptlet+HtmlUtils escape, CSRF) |
| w-016 | controller/api/GameCommentController.java | modify | OK (흡수, isOperator/ROLE_ADMIN/sessionRole 삭제) |
| w-017 | controller/api/GameReviewController.java | modify | OK (흡수, 동일) |
| w-018 | controller/api/UserController.java | modify | OK (세션 권한 스냅샷, 생성자 +UserPermissionsMapper) |
| w-019 | data/UserData.java | modify | OK (permissionsEpoch) |
| w-020 | test/BibimbapApplicationTests.java | modify | OK (@MockBean ×4 추가) |
| w-021 | test/AdminConsoleControllerTest.java | create | OK (13 케이스) |
| w-022 | test/security/PermissionGateTest.java | create | OK (7 케이스, AC-2 회수) |
| w-023 | test/GameCommentControllerTest.java | modify | OK (게이트 mock + SUBADMIN 신규) |
| w-024 | test/GameReviewControllerTest.java | modify | OK (동일) |
## advisor 직접 처리 (worker 미spawn — planned 24 vs actual 22)
1. **PermissionGate.isAdmin/isAuthenticated 추가** (w-011 산출 후 advisor Edit): 인터셉터가 ADMIN-only 게이트를 epoch 모델로 단일 소스화하려면 게이트에 메서드가 필요. 단일 파일·소수 라인 편집이라 신규 worker spawn 불필요(계량: 1파일 <20줄).
2. **PermissionCatalogVerifier null-guard** (advisor Edit): mock listActiveKeys() null 반환 시 contextLoads NPE 방지. 1파일 3줄.
3. **UserControllerCsrfTest 생성자 인자 보정** (advisor Edit): 영향 맵 밖 발견 파일(discovered dependency). UserController 생성자 변경(+UserPermissionsMapper)으로 기존 테스트 `new UserController(2-arg)` 컴파일 깨짐 → 3-arg + @Mock 추가. 2파일 위치 <4줄 기계적 수정 advisor 직접(계량: <500줄, 파일<8).
## Bash 단계 (advisor 직접)
- `./mvnw -o compile` (중간 검증, U-DOMAIN+gate 후) → EXIT 0.
- `./mvnw -o test` (full) → **BUILD SUCCESS, 65 tests, 0 fail/error/skip**.
- PermissionGateTest 7, AdminConsoleControllerTest 13, GameCommentControllerTest 18, GameReviewControllerTest 21, UserControllerCsrfTest 5, BibimbapApplicationTests(contextLoads) 1.
- 빌드 경고: `@MockBean` deprecation(Spring Boot 3.4+)만 — 기존 8필드에도 이미 존재하는 프로젝트 관례. unused/dead 경고 0.
- 환경: JAVA_HOME=/opt/homebrew/opt/openjdk@21 (셸 PATH 미설정 → export 로 해결).
## 설계 concerns 처리 결과 (구현 점검 항목)
1. **시그니처 inflate(concern 1)**: PermissionGate `has`/`canModerate` 의 `request` 파라미터 **제거 확정**. 게이트는 boolean 만 반환, 거부응답은 호출자(인터셉터/컨트롤러)가 작성. 흡수 호출지점 `permissionGate.canModerate(session)` (request 없음). `HttpServletRequest` import 0 (grep 확인). dead parameter 0.
2. **@MockBean / full-test(concern 2)**: BibimbapApplicationTests 에 PermissionsMapper/UserPermissionsMapper/RbacAuditMapper/PermissionGate @MockBean 4개 등록. full `./mvnw -o test` 실행 — contextLoads NoSuchBeanDefinitionException 없음(verifier null-guard 로 ApplicationRunner NPE 도 방지).
3. **DB-방언 L2(concern 3)**: 신규 매퍼 camelCase alias 전부 큰따옴표(`AS "permissionKey"` 등). `${}` 0건(PermissionsMapper/UserPermissionsMapper/RbacAuditMapper/UsersMapper 신규분 grep). dev DB contract 실행 검증은 verification-advisor + DDL 적용 후 가능(기존 open item).
4. **users 스키마 권위 수준(concern 4)**: DDL 멱등(IF NOT EXISTS / DO $$ guard). docs/rbac-ddl.sql 권위, db/schema.sql 사본 반영. bootstrap seed 는 글롭 밖(db/bootstrap-admin.sql) 수동 전용.
## 정적 AC 게이트 (구현 시점 self-check — 최종 판정은 verification)
- AC-T1: PermissionKeys 멤버 3 (grep -c = 3).
- AC-T2: AdminConsoleController CsrfTokens.isValid = 3 (appoint/toggle/demote).
- AC-T4: bumpPermissionsEpoch = 3 (동일 핸들러).
- AC-T3/AC-8: audit action 리터럴 APPOINT/DEMOTE/GRANT/REVOKE 4종 전수.
- AC-T5/AC-9: 신규 매퍼 4파일 `${` = 0.
## DDL 적용 대기 (orchestrator 게이트 필요)
- `db/apply-local-ddl.sh` **미실행**(금지 준수). docs/rbac-ddl.sql 작성까지만.
- orchestrator 가 게이트 후: `db/apply-local-ddl.sh docs/rbac-ddl.sql` (자동 글롭) + 최초 ADMIN 은 `psql -v admin_email=... -f db/bootstrap-admin.sql` 수동.
## 미해결 이슈
없음. 컴파일·full test 통과. DDL 실DB 적용/방언 L2 실행 검증은 orchestrator 게이트 + verification-advisor 영역.

View File

@ -0,0 +1,64 @@
---
phase: implementation
agent: implementation-advisor
agent_version: 1
generated_at: 2026-06-23T00:00:00Z
workstream: W1-거버넌스/RBAC
---
# 파일 소유권 맵 (W1 — RBAC/거버넌스)
의존 순서: U-SCHEMA → U-DOMAIN → {U-GATE, U-CONSOLE, U-ABSORB, U-SESSION}.
U-GATE 는 U-CONSOLE/U-ABSORB 의 공통 선행(컴파일 의존: PermissionGate 타입).
따라서 spawn 그룹:
- 그룹0 (병렬): U-SCHEMA(migration-writer) + U-DOMAIN 데이터/매퍼/enum (code-writer 다수) — 단 PermissionGate 미존재로 U-GATE/U-CONSOLE/U-ABSORB 는 대기.
- 의존 정밀화: U-DOMAIN 산출(PermissionKeys/Roles/매퍼/data) 완료 후 U-GATE → 완료 후 U-CONSOLE/U-ABSORB/U-SESSION + 테스트.
## 시그니처 inflate 결정 (concern 1 — request 제거)
PermissionGate 거부 응답은 호출자(인터셉터/컨트롤러)가 작성. 게이트는 boolean 반환.
`request` 파라미터 전부 제거. 확정 시그니처:
- `boolean has(HttpSession session, String permissionKey)`
- `boolean canModerate(HttpSession session)`
- `boolean require(HttpSession session, String permissionKey)` (require 도 boolean; 호출자 매핑. 실질 has 위임)
흡수 호출지점: `permissionGate.canModerate(session)` (request 인자 없음).
## 소유권 테이블
| 파일 | 담당 worker | worker id | 변경 유형 | 의존 |
|---|---|---|---|---|
| docs/rbac-ddl.sql | migration-writer | w-001 | create | - |
| db/bootstrap-admin.sql | migration-writer | w-001 | create | - |
| db/schema.sql | migration-writer | w-001 | modify | - |
| src/main/java/.../security/PermissionKeys.java | code-writer | w-002 | create | - |
| src/main/java/.../security/Roles.java | code-writer | w-003 | create | - |
| src/main/java/.../data/PermissionData.java | code-writer | w-004 | create | - |
| src/main/java/.../data/OperatorView.java | code-writer | w-005 | create | - |
| src/main/java/.../mapper/PermissionsMapper.java | code-writer | w-006 | create | PermissionData |
| src/main/java/.../mapper/UserPermissionsMapper.java | code-writer | w-007 | create | - |
| src/main/java/.../mapper/RbacAuditMapper.java | code-writer | w-008 | create | - |
| src/main/java/.../mapper/UsersMapper.java | code-writer | w-009 | modify | OperatorView |
| src/main/java/.../config/PermissionCatalogVerifier.java | code-writer | w-010 | create | PermissionKeys, PermissionsMapper |
| src/main/java/.../security/PermissionGate.java | code-writer | w-011 | create | Roles, UsersMapper, UserPermissionsMapper, PermissionKeys |
| src/main/java/.../security/RbacInterceptor.java | code-writer | w-012 | create | Roles, PermissionGate? (no — ADMIN role 직접 검사) |
| src/main/java/.../config/InterceptorConfig.java | code-writer | w-013 | create | RbacInterceptor |
| src/main/java/.../controller/AdminConsoleController.java | code-writer | w-014 | create | PermissionKeys, Roles, mappers, CsrfTokens |
| src/main/webapp/WEB-INF/views/admin-console.jsp | code-writer | w-015 | create | - |
| src/main/java/.../controller/api/GameCommentController.java | code-writer | w-016 | modify | PermissionGate |
| src/main/java/.../controller/api/GameReviewController.java | code-writer | w-017 | modify | PermissionGate |
| src/main/java/.../controller/api/UserController.java | code-writer | w-018 | modify | UserPermissionsMapper, UserData |
| src/main/java/.../data/UserData.java | code-writer | w-019 | modify | - |
| src/test/java/.../BibimbapApplicationTests.java | code-writer | w-020 | modify | new mappers, PermissionGate |
| src/test/.../AdminConsoleControllerTest.java | code-writer | w-021 | create | AdminConsoleController |
| src/test/.../PermissionGateTest.java | code-writer | w-022 | create | PermissionGate |
| src/test/.../GameCommentControllerTest.java | code-writer | w-023 | modify | GameCommentController(+gate) |
| src/test/.../GameReviewControllerTest.java | code-writer | w-024 | modify | GameReviewController(+gate) |
## 불변식 점검
- 동일 파일 1 worker 원칙 준수 (UsersMapper 는 w-009 단독, UserData 는 w-019 단독).
- 의존 있는 파일은 선행 그룹 완료 후 spawn (PermissionGate 의존 컨트롤러/테스트는 w-011 후).
- 스키마/seed/마이그레이션은 migration-writer(w-001) 로 격리.
## 계량 판단: 직접 실행 vs worker
파일 25개, 신규 코드 다수(enum/mapper/gate/interceptor/controller/jsp/test). 500줄 초과 확실.
→ worker 병렬화 이득 있음. planned_workers = 24 (논리 단위). 단 advisor 직접 실행으로
자잘한 의존 파일은 묶을 수 있음 (실제 spawn 수는 report 에 기록).

View File

@ -0,0 +1,86 @@
---
schema_version: 1
sid: 20260622-180054
started_at: 2026-06-22T18:00:54+09:00
ended_at:
user_request: "남은 W 작업들 우선 전체 구현하려는데 실현가능성 있어?"
mode: feasibility-assessment
---
# Summary
feasibility 질문 — 코드 변경 없음. 남은 W 워크스트림 전체를 한 번에 구현하는 것의 실현가능성 판정.
현 상태 (코드 grounding):
- 구현 완료: **W3-2 댓글/리뷰만** (GameReviewsMapper/GameReviewStatsMapper/GameReviewController 존재)
- 전부 부재: W1(Interceptor 0) · W2-1~W2-6 게임잼(jam 0) · W3-1 태그(tags 0) · W3-3 포스팅(board/posts 0) · W3-4 메인허브 · W3-5 Unity업로드(deferred) · W4 배지(badge 도메인 0)
- 규모감: controller 9 · mapper 9 (소~중형 레거시 Spring Boot+MyBatis+JSP)
판정: **"전체 한 번에"는 비현실적**. 사유 — 설계 미완(W3 윤곽 카탈로그 깊이, 미결질문 미해소 / W1·W2·W4 로드맵 깊이만), QG-1/2/3 미결, W3-5 선행 조사 필요, 외부 fetch SSRF 표면(W3-3). 의존 사슬상 순차 + design-gate 진행이 현실 경로.
# Invocations
[]
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements-advisor
decision: skip
rationale: '로드맵+W3 골자 카탈로그에 분해·의존·미결이 이미 확정 기록됨. feasibility 판정에 재분해 불필요.'
checked_at: 2026-06-22T18:01:00+09:00
- advisor: research-advisor
decision: skip
rationale: 'graphify-lookup 대신 orchestrator 직접 grounding 스캔으로 현 코드 상태(jam/interceptor/tags/board/badge 부재, review 존재) 확인 완료. 외부 자료 불요.'
checked_at: 2026-06-22T18:01:30+09:00
- advisor: design/implementation/verification
decision: defer
rationale: 'feasibility 질문 단계 — 구현 착수 아님. 사용자 scope 결정(plan gate) 후 해당 W 착수 시 호출.'
checked_at: 2026-06-22T18:02:00+09:00
- advisor: requirements-advisor (W1)
decision: call
rationale: '사용자 전략 = W1부터 의존 순차. W1은 로드맵 깊이뿐 — 권한 모델(단일 role→권한부여형 불가)·QG-1·기존 임시 ROLE_ADMIN 흡수 등 결정 미확정. design 진입 전 오픈질문 0 필요.'
checked_at: 2026-06-23T09:00:00+09:00
model_choice: { tier: inherit, rationale: 'foundational RBAC 권한 모델 결정은 결과 파급 큼 — parent(opus) 상속 유지' }
- advisor: design-advisor (W1)
decision: call
rationale: 'requirements 확정(FR14/NFR9/AC9, 오픈질문 0). 4개 결정 사용자 확정 — 2개 비권장(DB카탈로그·세션캐시무효화)이 설계 난제. DDL/계약/인터셉터/세션무효화 설계 필요.'
checked_at: 2026-06-23T09:15:00+09:00
model_choice: { tier: inherit, rationale: 'foundational RBAC 설계 — 세션 무효화·인터셉터 매핑 복잡. parent(opus) 상속' }
# W1 확정 결정 (사용자 2026-06-23)
decisions_W1:
- 결정1 권한모델: role(ADMIN/SUBADMIN/USER) + user_permissions join [권장 채택]
- 결정2 카탈로그: DB permissions 테이블 [비권장 선택 — 코드↔DB 키 동기화 계약 필요]
- 결정4 ADMIN흡수: 권한 게이트 통일(ADMIN 암묵전권 OR SUBADMIN+CONTENT_MODERATE) [권장 채택 — W3-2 회귀검증]
- 결정7 세션전파: 세션 캐시 + 변경 시 무효화 [비권장 선택 — 타 사용자 세션 무효화 메커니즘이 보안 난제]
- 결정3 부트스트랩: DB seed/수동 승격 [requirements 권장 — 미질문, 안전기본]
- 결정5 인터셉터: W1 포함, 보호범위 콘솔(ADMIN)/잼관리/포스팅 [QG-1 확정]
# user_signals
positive:
- "'W1부터 의존 순차'(Recommended) 1회 만에 수락 — 권장안 정합"
- "W1 4개 결정 1배치로 확정 — 2개는 비권장이지만 명확한 의지(유연성·성능 우선)"
negative: []
# Decisions
- 이번 턴은 코드 변경 0 — feasibility 판정 + 현실 경로 제시 + 사용자 scope 결정 요청(plan gate).
# verified_by_me
- (코드 변경 없음 — L1/L2 해당 없음)
- grounding: rg 스캔으로 jam/interceptor/tags/board/badge 도메인 부재, review 도메인 존재 확인
# needs_user_verification
- scope 결정: 전체 일괄 vs 의존 사슬 순차(권장)
# open_items
- (없음 — git clean 유지)
# graph_refresh
- (코드 변경 없음 — 세션 종료 시 판정)

View File

@ -0,0 +1,174 @@
---
phase: requirements
agent: requirements-advisor
agent_version: 1
generated_at: 2026-06-23T00:41:35Z
workstream: W1-거버넌스/RBAC
concerns:
- "권한 모델 형태(결정1)·관리자 부트스트랩(결정3)·인터셉터 보호범위(결정5)는 권장안을 제시했으나 사용자 최종 확정 필요 — AskUserQuestion 서브에이전트 불가로 orchestrator 경유 확인 요함"
- "RBAC 도입은 신규 DB 스키마(permissions/user_permissions)를 강제함 — schema.sql + DDL 적용 절차(maintenance) 동반 변경"
- "role 변경 시 세션 role attr(UserController:508) stale — 세션 동기화/재로그인 전략은 design 결정이나 보안 비기능 요구로 격상"
- "기존 임시 ROLE_ADMIN(.equals) 흡수 시 comment/review 모더레이션 동작 변경 — W3-2 산물 회귀 점검 필요"
concerns_checked: true
self_verification:
checklist_passed: true
---
# W1 — 거버넌스 / RBAC 요구사항
## 원 요청 (로드맵 §W1 인용)
> 관리자(전체 권한) / 부관리자(허용된 권한만 = 권한부여형) 모델 / 관리자 콘솔 — 부관리자 임명 + 권한 토글 / 권한 체크 인터셉터 / 권한 토글 항목 예시: 게임잼관리, 포스팅작성(=포스터 권한). 의존 없음. 공유자원 users, security/, 세션/인증.
---
## §0. RBAC 권한 생명주기 맵 (end-to-end — 구조적 gap-hunt)
순차 상태 전이가 내포된 개념(부관리자 임명→권한 토글→권한 회수/강등, role 변경→세션 반영)이 있으므로 표면 요구 너머의 단절을 적극 헌트한다. 각 단계에 현재 코드 상태 + 단절 여부를 표기.
| 단계 | 전이 | 현재 코드 상태 | 단절 |
|---|---|---|---|
| L0 | 가입 → USER 부여 | `UserController:295` 전원 `setRole("USER")` | OK |
| L1 | 최초 ADMIN 부트스트랩 | **경로 없음** — signup 전원 USER, 승격 수단 0 | **단절 — 갭 G1** |
| L2 | ADMIN → 부관리자(SUBADMIN) 임명 | 콘솔/임명 API 0 | **단절 — 갭 G2** |
| L3 | 부관리자에게 권한 토글 부여 | 권한 저장 구조 0 (role 단일 String) | **단절 — 갭 G3** |
| L4 | 요청 시 권한 게이트 통과 검사 | Interceptor 0, `addInterceptors` 0 | **단절 — 갭 G4 (QG-1 핵심)** |
| L5 | 권한 토글 **회수**(역방향) | 회수 API·세션 무효화 0 | **단절 — 갭 G5 (역방향 전이)** |
| L6 | 부관리자 → USER **강등/해임**(종료) | 강등 경로 0 | **단절 — 갭 G6 (종료 전이)** |
| L7 | role/권한 변경 후 **세션 반영** | 세션에 `role` attr 박제(`:508`), 변경 전파 0 → **stale 권한** | **단절 — 갭 G7 (2차 단절·보안)** |
> happy-path(L0~L4)만이 아니라 **회수/강등/세션 무효화**(L5~L7)를 반드시 W1 스코프에 포함한다. 권한을 줄 수만 있고 회수·전파가 없으면 거버넌스로서 미완이며 보안 결함(권한 회수 후에도 세션 유효)이 된다.
### 복수 이벤트 조건 대칭 점검 (병렬 분기)
- **권한 부여 시 / 권한 회수 시** 세션 반영이 대칭이어야 한다. 부여만 즉시 반영하고 회수는 다음 로그인까지 지연되면 보안 비대칭(회수 우회). → 결정축에 반영(아래 결정7).
- **임명(승격) 시 / 해임(강등) 시** 감사 로그 기록이 대칭이어야 한다. → NFR-보안 감사로그.
---
## 기능 요구 (FR)
### 권한 모델·저장
- **FR-1**: 사용자는 ADMIN / SUBADMIN / USER 중 하나의 기본 role을 가진다. (`users.role` 재사용, 값 집합 확장)
- **FR-2**: SUBADMIN(부관리자)은 0개 이상의 개별 권한(permission)을 부여받을 수 있다. ADMIN은 전 권한을 암묵적으로 보유한다(권한 토글 무관). USER는 권한 0.
- **FR-3**: 권한 카탈로그는 최소 `GAME_JAM_MANAGE`(게임잼관리), `POST_WRITE`(포스팅작성/포스터)를 포함한다. 모더레이션 권한(`CONTENT_MODERATE`)을 추가 후보로 둔다(결정4 연계).
### 관리자 콘솔 (L1~L6)
- **FR-4**: ADMIN은 콘솔에서 사용자를 SUBADMIN으로 **임명**(승격)할 수 있다. (G2)
- **FR-5**: ADMIN은 콘솔에서 SUBADMIN의 각 권한을 **토글(부여/회수)**할 수 있다. (G3, G5 — 부여·회수 대칭)
- **FR-6**: ADMIN은 SUBADMIN을 USER로 **강등/해임**할 수 있고, 강등 시 부여 권한은 전부 회수된다. (G6)
- **FR-7**: 콘솔은 현재 운영진(ADMIN/SUBADMIN) 목록과 각자의 권한 상태를 조회한다.
- **FR-8**: ADMIN 권한 자체는 콘솔에서 부여하지 않는다(최초 ADMIN은 부트스트랩 전용 — 결정3). 콘솔에서 다루는 임명 대상은 SUBADMIN과 그 권한에 한정한다.
### 권한 체크 인터셉터 (L4 — QG-1 선행)
- **FR-9**: `HandlerInterceptor` 구현 + `addInterceptors` 등록으로 보호 경로 진입 시 권한을 검사한다.
- **FR-10**: 보호 대상 — (a) 관리자 콘솔 경로 전체(ADMIN only), (b) 게임잼관리 액션(`GAME_JAM_MANAGE` 보유자 — W2 소비), (c) 포스팅 작성 액션(`POST_WRITE` 보유자 — W3-3 소비). 미보유 시 거부(미인증=401/로그인 리다이렉트, 인증·미인가=403).
- **FR-11**: 인터셉터는 세션 식별값(`userId`)을 기준으로 권한을 판정한다. 세션 role attr 단독 신뢰 여부는 결정7(세션 stale)에 종속.
### 부트스트랩 (L1)
- **FR-12**: 최초 ADMIN은 운영 DB seed/수동 승격으로 지정한다(결정3 권장안). 코드 자동 승격 경로는 두지 않는다.
### 기존 임시 ADMIN 흡수 (결정4)
- **FR-13**: comment/review 모더레이션 체크(`GameCommentController:201`, `GameReviewController:419``ROLE_ADMIN.equals(role)`)를 W1 권한 게이트(운영자 판정)로 대체한다. ADMIN + `CONTENT_MODERATE` 보유 SUBADMIN이 통과하도록 재정의한다. (권장안 — 사용자 확정 시)
### 세션·권한 전파 (L7 — 2차 단절)
- **FR-14**: role/권한 변경(임명·토글·회수·강등) 후, 대상 사용자의 후속 요청에서 새 권한이 반영되어야 한다. (부여·회수 **대칭** — 결정7)
---
## 비기능 요구 (NFR)
- **NFR-보안(CSRF)**: 콘솔의 모든 상태변경(임명/토글/회수/강등)은 `CsrfTokens.isValid` 검증 적용. (기존 패턴 — comment/review/UserController 일관)
- **NFR-보안(SQL)**: 권한 조회·갱신 MyBatis SQL은 `#{}` 바인딩만 사용, `${}` 금지.
- **NFR-보안(인가 경계)**: 권한 판정은 서버측 인터셉터/컨트롤러에서 수행. 클라이언트(JSP) 노출은 표시용일 뿐 게이트 아님. 콘솔 진입·임명 API는 ADMIN 이외 도달 시 403.
- **NFR-보안(세션 무결성)**: 권한 변경 시 stale 세션 권한 방지(FR-14). 권한 **상승은 즉시, 회수는 즉시** 반영을 목표(비대칭 금지). 세션 고정 방어(`changeSessionId` — 로그인 시 이미 존재)는 보존.
- **NFR-보안(감사 로그)**: 임명/강등/권한 토글은 누가·누구를·언제·무엇을 변경했는지 기록(감사 추적). 임명·해임 **대칭** 기록.
- **NFR-운영(롤백/마이그레이션)**: 신규 권한 스키마는 schema.sql + 적용 DDL로 제공, 기존 데이터(전원 role='USER')와 호환(추가만, 파괴 없음). 롤아웃 순서: 스키마 적용 → 부트스트랩 ADMIN 지정 → 인터셉터 배포.
- **NFR-호환성**: 기존 `users.role` 컬럼·세션 role attr·comment/review 동작을 보존하거나 명시적으로 대체(FR-13). USER 기존 사용자에 영향 없음.
- **NFR-i18n**: 콘솔 UI 문자열은 기존 JSP 한국어 직접 출력 패턴 따름(별도 i18n 프레임워크 없음 — 해당 없음 수준).
- **NFR-접근성**: 콘솔은 관리자 전용 내부 화면 — 표준 폼/키보드 조작 보장 외 특별 요건 없음(해당 최소).
- **NFR-성능**: 권한 체크는 요청당 1회 발생 — 권한 조회 캐시/세션 캐싱 여부는 design 결정(stale 트레이드오프와 결합, 결정7). 상한 요구 없음.
---
## 6개 핵심 결정의 확정값 (권장안 + 트레이드오프)
> AskUserQuestion이 서브에이전트에서 불가하여, 각 결정에 권장안을 명시하고 사용자 확정이 필요한 항목은 [확정필요]로 표기했다. design 진입 전 orchestrator가 사용자에게 확인.
### 결정1 — 권한 모델 형태 [확정필요·권장 (a)]
- **권장 (a)**: `users.role`(ADMIN/SUBADMIN/USER) 유지 + `user_permissions` join 테이블(user_id × permission). 부관리자별 개별 토글을 직접 표현.
- 근거: 기존 `.equals(role)`(comment/review) + 세션 role attr 코드를 **호환 보존**하면서 부분집합을 추가. 변경 표면 최소.
- 트레이드오프: 판정이 role 분기 + permission 조회 2단계. (b)보다 모델이 약간 복잡.
- (b) user↔permission 직접: role 개념 제거 — 기존 ADMIN 체크/세션 전면 교체 비용. 기각.
- (c) role↔permission 매핑: 역할 단위라 "부관리자 개인별 토글" 요구와 불일치. 기각.
### 결정2 — 초기 permission 카탈로그 [권장 확정: DB 카탈로그 + 2~3권한]
- **권장**: `GAME_JAM_MANAGE`, `POST_WRITE` 출시 포함 + (결정4 채택 시) `CONTENT_MODERATE`. 카탈로그를 **DB 테이블**(`permissions`)로 관리해 운영자 추가·향후 W2-2(심사위원)/W4(배지)와 분리 흡수 가능.
- 트레이드오프: DB 카탈로그는 코드 enum 대비 타입 안전성↓, 권한 키 오타 가능 → permission 키는 코드 상수와 DB 시드를 동기화하는 계약 필요(design).
- 대안: 코드 enum(단순·타입안전, 추가 시 배포). W1 권한이 2~3개로 적어 enum도 무리 없음 — [확정필요] 여지.
### 결정3 — 관리자 부트스트랩 [확정필요·권장: DB seed/수동 승격]
- **권장**: 운영 DB에서 특정 user.role을 ADMIN으로 직접 UPDATE(seed 스크립트 또는 1회 수동 maintenance). 코드 변경·설정 노출 없음.
- 근거: 가장 안전(공격면 0), 순환문제(콘솔 임명은 ADMIN 선존 필요) 회피.
- 트레이드오프: 운영 수동 절차 1회 필요 → maintenance 문서화 동반.
- 대안: 설정 기반 자동 승격(이메일 목록) — 설정 노출·관리 부담으로 비권장.
### 결정4 — 기존 임시 ROLE_ADMIN 흡수 [확정필요·권장: 권한 게이트로 통일]
- **권장**: comment/review 모더레이션을 ADMIN + `CONTENT_MODERATE` 보유자 통과로 재정의(FR-13). 부관리자도 모더레이션 권한 토글 가능.
- 근거: 임시 산물(W3-2)을 W1 체계로 흡수해 권한 일원화. 미루면 ADMIN 하드코딩 분산 유지.
- **2차 단절 점검**: 변경 시 기존 ADMIN 통과 동작이 끊기면 안 됨 → ADMIN은 모든 권한 암묵 보유(FR-2)로 회귀 방지. W3-2 모더레이션 테스트 회귀 확인 필요(concern).
- 대안: 현행 `.equals("ADMIN")` 호환만 유지(부관리자 모더레이션 불가, 최소 변경) — W1 일원화 목적엔 미달.
### 결정5 — 인터셉터 보호 범위 (QG-1) [확정필요·권장 범위 확정]
- **권장**: W1 출시에 인터셉터 **포함 확정**(W3-3가 이를 선행 의존 — QG-1). 보호 대상:
- 관리자 콘솔 경로 전체 → ADMIN only.
- 게임잼관리 액션 → `GAME_JAM_MANAGE` (W2가 경로 확정 시 등록, W1은 게이트 인프라 제공).
- 포스팅 작성 액션 → `POST_WRITE` (W3-3 소비).
- 근거: QG-1에서 "W3-3은 W1 완료 후 착수(임시 체크 안 함)" 이미 확정 → 인터셉터가 W1 산출에 반드시 포함.
- 트레이드오프: 보호 경로 매핑 방식(URL 패턴 vs 어노테이션 vs 핸들러 메타) = design 결정. 미인증(401/리다이렉트) vs 미인가(403) 응답 정책 = design 확정.
### 결정6 — 관리자 콘솔 범위 [권장 최소 범위]
- **권장 최소**: (1) 운영진 목록 조회(FR-7), (2) 부관리자 임명(FR-4), (3) 권한 토글 부여/회수(FR-5), (4) 강등/해임(FR-6). 4개 액션. 모두 CSRF 보호.
- 제외(후속): 권한 변경 이력 열람 UI, 일괄 작업, 사용자 검색 고도화.
### 결정7 (신규 — gap-hunt 산물) — 세션 권한 전파(stale) [확정필요·권장: 즉시 반영, 부여·회수 대칭]
- 배경: 세션에 `role` attr 박제(`UserController:508`). 인터셉터가 세션 role만 신뢰하면 권한 변경이 다음 로그인까지 미반영 → **회수 우회 보안 결함**(L7/G7).
- **권장**: 인터셉터는 권한 판정 시 권위 소스(DB user_permissions 또는 세션과 동기화된 캐시)를 신뢰. 변경 시 부여·회수 **대칭 즉시 반영**.
- 트레이드오프: 매 요청 DB 조회(성능) vs 세션 캐시(stale 위험). design에서 캐시 TTL/무효화 전략 결정. **단 회수의 즉시성은 비기능 보안 요구로 양보 불가.**
---
## 스코프
- **포함**: ADMIN/SUBADMIN/USER role 확장, user_permissions 저장, 권한 카탈로그(2~3개), 관리자 콘솔 4액션(임명·토글·회수·강등 — 부여/회수 대칭), 권한 체크 인터셉터(QG-1), 부트스트랩 절차, 기존 ADMIN 흡수(결정4 채택 시), 세션 권한 전파(결정7).
- **제외**: 심사위원 역할(W2-2), 리뷰어/기술자 배지(W4), 게임잼관리 액션의 실제 비즈니스(W2 — W1은 게이트만 제공), 포스팅 작성 기능 본체(W3-3 — W1은 권한만 제공), 권한 변경 이력 UI·일괄작업(후속).
---
## 가정 / 추측
- (가정) `users.role` 컬럼(varchar(30), DEFAULT 'USER')을 그대로 재사용해 값 집합만 확장 — schema.sql:35 근거. role 컬럼 자체 신규 추가 아님.
- (가정) 세션 고정 방어(`changeSessionId`)는 로그인에 이미 존재(`UserController:160`)하므로 W1은 보존만, 재구현 불필요.
- (가정) comment/review의 CSRF·canModify 패턴이 W1 콘솔의 참조 표준(동일 `CsrfTokens.isValid`).
- (추측→concern) RBAC 도입은 신규 DB 스키마를 강제함 → schema.sql + DDL 적용 + maintenance 문서 동반 변경. concerns에 이관.
## 확정 필요 (오픈 질문 — orchestrator 경유 사용자 확인)
> 서브에이전트 AskUserQuestion 불가로 권장안 채택을 전제하되, 아래는 사용자 명시 확정이 바람직한 항목. 미응답 시 권장안을 design 가정으로 진행 가능(파괴적 결정 아님).
- **Q1 (결정1)**: 권한 모델 = role + user_permissions join (권장 a) 채택 확인.
- **Q2 (결정3)**: 부트스트랩 = DB seed/수동 승격 (권장) 채택 확인.
- **Q3 (결정4)**: 기존 ADMIN 흡수 = 권한 게이트로 통일(`CONTENT_MODERATE`) 채택 확인. 미채택 시 comment/review 현행 유지.
- **Q4 (결정5)**: 인터셉터 W1 포함 + 보호범위(콘솔 ADMIN / 잼관리 / 포스팅) 확인. (QG-1상 사실상 포함 확정이나 범위 동의 필요)
- **Q5 (결정2)**: 권한 카탈로그 저장 = DB vs 코드 enum 택1.
- **Q6 (결정7)**: 권한 회수 즉시 반영(부여/회수 대칭) 요구 동의 — 보안상 강한 권고.
## Acceptance Criteria 후보 (design이 만족시킬 목표)
- AC-1: ADMIN이 임명한 SUBADMIN에게 `POST_WRITE`를 토글하면, 해당 사용자가 포스팅 작성 액션에 통과한다.
- AC-2: 위 권한을 회수하면, **다음 요청부터** 동일 액션이 403으로 거부된다(즉시·세션 잔류 없음 — 결정7).
- AC-3: SUBADMIN을 강등하면 모든 권한이 회수되고 콘솔 운영진 목록에서 SUBADMIN으로 표시되지 않는다.
- AC-4: 비-ADMIN이 관리자 콘솔 경로에 접근하면 403/리다이렉트로 차단된다(인터셉터 게이트).
- AC-5: 콘솔의 모든 상태변경 요청은 CSRF 토큰 없으면 403.
- AC-6: 부트스트랩 절차로 지정된 최초 ADMIN만 콘솔에 진입 가능(자동 승격 경로 부재).
- AC-7: (결정4 채택 시) comment/review 모더레이션이 ADMIN + `CONTENT_MODERATE` 보유자에게 통과하고, 기존 ADMIN 통과 동작은 회귀 없이 보존(W3-2 테스트 PASS 유지).
- AC-8: 임명/토글/회수/강등이 감사 로그에 대칭 기록된다.
- AC-9: 권한 관련 SQL에 `${}` 동적 치환이 없다.
## design-advisor 전달 단절 목록 (명시 인계)
- G1 부트스트랩, G2 임명, G3 권한토글부여, G4 인터셉터(QG-1), **G5 권한회수(역방향)**, **G6 강등/해임(종료)**, **G7 세션 권한 전파(2차 단절·보안)**.
- 병렬 대칭: 권한 부여/회수 세션 반영 대칭, 임명/해임 감사로그 대칭.