bibimbap/.atp/work-session/20260622-180054/implementation/W1-design.md

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
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 절차로 분리.
true
checklist_passed
true
requirements research adrs
.atp/work-session/20260622-180054/research/W1-requirements.md null
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 isOperatorPermissionGate.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): usersdb/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.sqlusers 블록 뒤(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_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 방지)

// 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/canModeraterequest 파라미터는 "거부 응답을 게이트가 직접 작성하는가, 아니면 호출자가 작성하는가"에 따라 사용 여부가 갈린다. 본 설계는 거부 응답을 호출자(인터셉터/컨트롤러)가 작성하고 게이트는 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.sqldb/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 에 이관.