37 KiB
| phase | agent | agent_version | generated_at | workstream | concerns | concerns_checked | self_verification | references | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| design | design-advisor | 1 | 2026-06-23T00:00:00Z | W1-거버넌스/RBAC |
|
true |
|
|
설계: 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 동기화): 단일 정의처는 코드의
PermissionKeysenum 상수다. 부팅 시PermissionCatalogVerifier(ApplicationRunner)가 각 enum 키를 DBpermissions에 멱등 시드(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 글롭으로 자동 적용)
-- 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에 안 걸림. 자동 적용 금지가 의도):
-- 최초 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}` |
| 강등/해임(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_loginsert. 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)내부:- 세션
userId없음 → 미인증(false, 401/redirect). - 세션 캐시된
role·permissions(Set)·permsEpoch읽기. - 요청당 1회
usersMapper.getPermissionsEpoch(userId)(PK 단일조회) →dbEpoch. dbEpoch != sessionPermsEpoch면:role재조회 +userPermissionsMapper.listKeys(userId)재로딩 → 세션 갱신(role,permissions,permsEpoch=dbEpoch).- 판정:
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 방지)
// 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-운영)
- 스키마 적용:
docs/rbac-ddl.sql→db/apply-local-ddl.sh(로컬) / 운영은 동일 멱등 DDL 수동 적용. 기존 전원 role='USER' 호환(추가만, 파괴 0).permissions_epochDEFAULT 0 → 기존 사용자 세션 epoch(미설정=0)와 정합. - 코드 배포: PermissionKeys/PermissionGate/Interceptor/콘솔/흡수. 부팅 시
PermissionCatalogVerifier가 permissions 카탈로그 시드. - 부트스트랩 ADMIN:
db/bootstrap-admin.sql로 최초 ADMIN 1회 수동 지정(epoch +1 포함). 이후 콘솔 진입 가능. - 인터셉터 활성 → 보호 경로 게이트 발효.
역호환
- 기존 USER: role/세션 attr 불변, comment/review 작성·본인 수정 동작 불변(canModify 의 작성자 본인 분기 보존).
- 기존 ADMIN(부트스트랩 전): 흡수 후에도
role==ADMIN분기로 모더레이션 통과 보존(AC-7). - 세션 attr
role보존 — 신규permissions/permsEpochattr 추가만. 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건 정합 —
PermissionKeysenum 멤버 수 == 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_checkCHECK 의 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 에 이관.