feat(rbac): W1 거버넌스/RBAC — 관리자 콘솔·권한 게이트·세션 epoch 전파

- role(ADMIN/SUBADMIN/USER) + user_permissions join 권한 모델 도입. ADMIN 암묵 전권, SUBADMIN 부여 키만, USER 0.
- permissions 카탈로그(DB 테이블) + PermissionKeys enum 단일 정의처. 부팅 시 PermissionCatalogVerifier 가 enum→DB 멱등 시드·불일치 경고(코드↔DB 동기화 계약).
- PermissionGate(판정 코어) + RbacInterceptor(/admin/** ADMIN 게이트) + InterceptorConfig. 미인증 401/redirect·미인가 403·CSRF 실패 403 정책 확정.
- AdminConsoleController 4액션(임명/권한토글/강등/운영진 목록) + admin-console.jsp. 상태변경 전부 CsrfTokens.isValid 선검증.
- users.permissions_epoch 스탬프 + 요청당 PK 단일조회 대조로 권한 회수 즉시 반영. Spring Session 부재(톰캣 in-memory)로 타 세션 직접 무효화 불가한 제약을 epoch 대조로 우회 — 회수 우회 차단.
- comment/review 모더레이션 ROLE_ADMIN.equals(role) → PermissionGate.canModerate(ADMIN OR SUBADMIN+CONTENT_MODERATE) 흡수. ADMIN 통과 동작 회귀 보존.
- rbac_audit_log 임명/강등/부여/회수 대칭 기록.
- DDL: docs/rbac-ddl.sql(멱등·비파괴, 기존 전원 role=USER 호환), db/bootstrap-admin.sql(최초 ADMIN 수동 seed — 자동 승격 경로 부재).

검증: ./mvnw -o test 65/65 GREEN (PermissionGateTest 7 · AdminConsoleControllerTest 13 · GameComment/ReviewControllerTest 흡수 회귀 PASS). DDL 로컬 적용·L2(DB 방언 계약)·L3(스모크)는 미수행 — 별도 결정으로 분리.

설계: .atp/work-session/20260622-180054/implementation/W1-design.md
요구: .atp/work-session/20260622-180054/research/W1-requirements.md

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 e0eb8eae15
commit 941f9fb128
27 changed files with 1877 additions and 40 deletions

5
db/bootstrap-admin.sql Normal file
View File

@ -0,0 +1,5 @@
-- 최초 ADMIN 지정. 운영자가 대상 user 를 식별해 1회 수동 실행.
-- 자동 승격 경로 부재(FR-12, AC-6) — 코드/설정 노출 0.
-- 이 파일은 docs/*-ddl.sql 글롭 밖이라 apply-local-ddl.sh 가 자동 적용하지 않는다(의도된 분리).
UPDATE "users" SET "role" = 'ADMIN', "permissions_epoch" = "permissions_epoch" + 1
WHERE "canonical_email" = :'admin_email' AND "is_delete" IS NOT TRUE;

View File

@ -42,6 +42,19 @@ CREATE TABLE IF NOT EXISTS "users" (
);
ALTER SEQUENCE "users_id_seq" OWNED BY "users"."id";
-- W1 RBAC: users.role 값집합 확장(CHECK) + permissions_epoch (권위: docs/rbac-ddl.sql)
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
$$;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "permissions_epoch" bigint DEFAULT 0 NOT NULL;
-- ---------------------------------------------------------------------------
-- user_auth_identities (비권위 복원본 + security-hardening active-unique index)
-- ---------------------------------------------------------------------------
@ -291,3 +304,65 @@ CREATE INDEX IF NOT EXISTS "idx_recruit_posts_role"
CREATE INDEX IF NOT EXISTS "idx_recruit_posts_participation_type"
ON "recruit_posts" ("participation_type")
WHERE "is_delete" = false AND "is_visible" = true;
-- ---------------------------------------------------------------------------
-- W1 RBAC: permissions / user_permissions / rbac_audit_log
-- (권위 DDL — docs/rbac-ddl.sql 와 동일 스펙)
-- ---------------------------------------------------------------------------
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");
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");
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,
"target_id" bigint NOT NULL,
"action" character varying(30) NOT NULL,
"permission_key" character varying(50),
"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
$$;

80
docs/rbac-ddl.sql Normal file
View File

@ -0,0 +1,80 @@
-- W1 거버넌스/RBAC. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
-- 기존 데이터(전원 role='USER') 호환 — 추가만, 파괴 없음.
-- 1) users.role 값집합 확장 (CHECK 멱등 추가)
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 (세션 권한 전파 버전 스탬프)
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 (권한 카탈로그)
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)
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 (감사로그 — 임명/강등/토글 대칭 기록)
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,
"target_id" bigint NOT NULL,
"action" character varying(30) NOT NULL,
"permission_key" character varying(50),
"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
$$;

View File

@ -0,0 +1,21 @@
package com.pandoli365.bibimbap.config;
import com.pandoli365.bibimbap.security.RbacInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
private final RbacInterceptor rbacInterceptor;
public InterceptorConfig(RbacInterceptor rbacInterceptor) {
this.rbacInterceptor = rbacInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rbacInterceptor).addPathPatterns("/admin/**");
}
}

View File

@ -0,0 +1,49 @@
package com.pandoli365.bibimbap.config;
import com.pandoli365.bibimbap.mapper.PermissionsMapper;
import com.pandoli365.bibimbap.security.PermissionKeys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Component
public class PermissionCatalogVerifier implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(PermissionCatalogVerifier.class);
private final PermissionsMapper permissionsMapper;
public PermissionCatalogVerifier(PermissionsMapper permissionsMapper) {
this.permissionsMapper = permissionsMapper;
}
@Override
public void run(ApplicationArguments args) {
// 시드(멱등): enum 전체 순회 멤버 추가 자동 동기되는 불변식
for (PermissionKeys key : PermissionKeys.values()) {
permissionsMapper.upsert(key.name(), key.displayName());
}
// 불일치 경고: DB 활성 코드 enum 없는 탐지
Set<String> codeKeys = Arrays.stream(PermissionKeys.values())
.map(PermissionKeys::name)
.collect(Collectors.toSet());
List<String> dbKeys = permissionsMapper.listActiveKeys();
if (dbKeys != null) {
for (String dbKey : dbKeys) {
if (!codeKeys.contains(dbKey)) {
log.warn("[RBAC] DB permissions 에 코드 enum 에 없는 활성 권한 키 발견: {} — 코드 PermissionKeys 와 불일치", dbKey);
}
}
}
log.info("[RBAC] 권한 카탈로그 시드 완료: {} 키", PermissionKeys.values().length);
}
}

View File

@ -0,0 +1,202 @@
package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.OperatorView;
import com.pandoli365.bibimbap.data.UserData;
import com.pandoli365.bibimbap.mapper.PermissionsMapper;
import com.pandoli365.bibimbap.mapper.RbacAuditMapper;
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
import com.pandoli365.bibimbap.mapper.UsersMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.security.PermissionKeys;
import com.pandoli365.bibimbap.security.Roles;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Controller
public class AdminConsoleController {
private final UsersMapper usersMapper;
private final UserPermissionsMapper userPermissionsMapper;
private final RbacAuditMapper auditMapper;
private final PermissionsMapper permissionsMapper;
public AdminConsoleController(UsersMapper usersMapper,
UserPermissionsMapper userPermissionsMapper,
RbacAuditMapper auditMapper,
PermissionsMapper permissionsMapper) {
this.usersMapper = usersMapper;
this.userPermissionsMapper = userPermissionsMapper;
this.auditMapper = auditMapper;
this.permissionsMapper = permissionsMapper;
}
@GetMapping("/admin/console")
public String console(Model model, HttpServletRequest request) {
model.addAttribute("operators", buildOperators());
model.addAttribute("catalog", permissionsMapper.listActive());
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession()));
return "admin-console";
}
@PostMapping("/admin/users/{userId}/appoint")
@Transactional
public ResponseEntity<Map<String, Object>> appoint(@PathVariable("userId") long userId,
HttpServletRequest request,
HttpSession session) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
UserData target = usersMapper.getUser(userId);
if (target == null) {
return response(HttpStatus.NOT_FOUND, "대상 사용자를 찾을 수 없습니다.");
}
if (!Roles.USER.equals(target.getRole())) {
return response(HttpStatus.CONFLICT, "이미 운영진이거나 승격 대상이 아닙니다.");
}
Long actorId = sessionUserId(session);
usersMapper.updateRole(userId, Roles.SUBADMIN);
usersMapper.bumpPermissionsEpoch(userId);
auditMapper.insert(actorId, userId, "APPOINT", null);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", HttpStatus.OK.value());
body.put("message", "운영진으로 임명했습니다.");
body.put("userId", userId);
body.put("role", Roles.SUBADMIN);
return ResponseEntity.ok(body);
}
@PostMapping("/admin/users/{userId}/permissions/{permissionKey}/toggle")
@Transactional
public ResponseEntity<Map<String, Object>> togglePermission(@PathVariable("userId") long userId,
@PathVariable("permissionKey") String permissionKey,
HttpServletRequest request,
HttpSession session) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
if (!PermissionKeys.isValid(permissionKey)) {
return response(HttpStatus.NOT_FOUND, "존재하지 않는 권한 키입니다.");
}
UserData target = usersMapper.getUser(userId);
if (target == null) {
return response(HttpStatus.NOT_FOUND, "대상 사용자를 찾을 수 없습니다.");
}
if (!Roles.SUBADMIN.equals(target.getRole())) {
return response(HttpStatus.UNPROCESSABLE_ENTITY, "권한은 SUBADMIN 에게만 부여할 수 있습니다.");
}
Long actorId = sessionUserId(session);
boolean granted;
if (userPermissionsMapper.exists(userId, permissionKey)) {
userPermissionsMapper.delete(userId, permissionKey);
granted = false;
} else {
userPermissionsMapper.insert(userId, permissionKey, actorId);
granted = true;
}
usersMapper.bumpPermissionsEpoch(userId);
auditMapper.insert(actorId, userId, granted ? "GRANT" : "REVOKE", permissionKey);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", HttpStatus.OK.value());
body.put("message", granted ? "권한을 부여했습니다." : "권한을 회수했습니다.");
body.put("userId", userId);
body.put("permissionKey", permissionKey);
body.put("granted", granted);
return ResponseEntity.ok(body);
}
@PostMapping("/admin/users/{userId}/demote")
@Transactional
public ResponseEntity<Map<String, Object>> demote(@PathVariable("userId") long userId,
HttpServletRequest request,
HttpSession session) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
UserData target = usersMapper.getUser(userId);
if (target == null) {
return response(HttpStatus.NOT_FOUND, "대상 사용자를 찾을 수 없습니다.");
}
if (!Roles.SUBADMIN.equals(target.getRole())) {
return response(HttpStatus.UNPROCESSABLE_ENTITY, "강등 대상이 아닙니다.");
}
Long actorId = sessionUserId(session);
int revokedCount = userPermissionsMapper.deleteAllByUser(userId);
usersMapper.updateRole(userId, Roles.USER);
usersMapper.bumpPermissionsEpoch(userId);
auditMapper.insert(actorId, userId, "DEMOTE", null);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", HttpStatus.OK.value());
body.put("message", "운영진에서 해임했습니다.");
body.put("userId", userId);
body.put("role", Roles.USER);
body.put("revokedCount", revokedCount);
return ResponseEntity.ok(body);
}
@GetMapping("/admin/operators")
public ResponseEntity<Map<String, Object>> operators() {
List<OperatorView> ops = buildOperators();
List<Map<String, Object>> operatorList = new ArrayList<>();
for (OperatorView op : ops) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("userId", op.getUserId());
entry.put("displayName", op.getDisplayName());
entry.put("role", op.getRole());
entry.put("permissions", op.getPermissionKeys());
operatorList.add(entry);
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", HttpStatus.OK.value());
body.put("operators", operatorList);
return ResponseEntity.ok(body);
}
private List<OperatorView> buildOperators() {
List<OperatorView> ops = usersMapper.listOperators();
for (OperatorView op : ops) {
op.setPermissionKeys(userPermissionsMapper.listKeys(op.getUserId()));
}
return ops;
}
private Long sessionUserId(HttpSession session) {
if (session == null) {
return null;
}
Object userId = session.getAttribute("userId");
if (userId instanceof Number number) {
return number.longValue();
}
if (userId instanceof String text) {
try {
return Long.parseLong(text);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}

View File

@ -4,6 +4,7 @@ import com.pandoli365.bibimbap.data.GameCommentData;
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.security.PermissionGate;
import com.pandoli365.bibimbap.util.TextNormalizer;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
@ -28,14 +29,15 @@ public class GameCommentController {
private static final int CONTENT_MAX = 200;
private static final int PAGE_SIZE = 20;
private static final String ROLE_ADMIN = "ADMIN";
private final GameCommentsMapper gameCommentsMapper;
private final GamesMapper gamesMapper;
private final PermissionGate permissionGate;
public GameCommentController(GameCommentsMapper gameCommentsMapper, GamesMapper gamesMapper) {
public GameCommentController(GameCommentsMapper gameCommentsMapper, GamesMapper gamesMapper, PermissionGate permissionGate) {
this.gameCommentsMapper = gameCommentsMapper;
this.gamesMapper = gamesMapper;
this.permissionGate = permissionGate;
}
@GetMapping("/game/{id}/comments")
@ -129,7 +131,7 @@ public class GameCommentController {
if (comment == null || !Long.valueOf(id).equals(comment.getGameId())) {
return response(HttpStatus.NOT_FOUND, "덧글을 찾을 수 없습니다.");
}
if (!canModify(userId, comment.getUserId(), sessionRole(session))) {
if (!canModify(userId, comment.getUserId(), session)) {
return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다.");
}
@ -168,7 +170,7 @@ public class GameCommentController {
if (comment == null || !Long.valueOf(id).equals(comment.getGameId())) {
return response(HttpStatus.NOT_FOUND, "덧글을 찾을 수 없습니다.");
}
if (!canModify(userId, comment.getUserId(), sessionRole(session))) {
if (!canModify(userId, comment.getUserId(), session)) {
return response(HttpStatus.FORBIDDEN, "작성자만 삭제할 수 있습니다.");
}
@ -197,12 +199,8 @@ public class GameCommentController {
return "newest".equals(sort) ? "newest" : "oldest";
}
private boolean isOperator(String role) {
return ROLE_ADMIN.equals(role);
}
private boolean canModify(Long currentUserId, Long authorUserId, String role) {
return (authorUserId != null && authorUserId.equals(currentUserId)) || isOperator(role);
private boolean canModify(Long currentUserId, Long authorUserId, HttpSession session) {
return (authorUserId != null && authorUserId.equals(currentUserId)) || permissionGate.canModerate(session);
}
private Long sessionUserId(HttpSession session) {
@ -231,14 +229,6 @@ public class GameCommentController {
return displayName instanceof String text ? text : null;
}
private String sessionRole(HttpSession session) {
if (session == null) {
return null;
}
Object role = session.getAttribute("role");
return role instanceof String text ? text : null;
}
private String trimToNull(String value) {
if (value == null) {
return null;

View File

@ -7,6 +7,7 @@ import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.security.PermissionGate;
import com.pandoli365.bibimbap.util.TextNormalizer;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
@ -34,22 +35,24 @@ public class GameReviewController {
private static final int BODY_MIN = 10;
private static final int BODY_MAX = 1000;
private static final int PAGE_SIZE = 20;
private static final String ROLE_ADMIN = "ADMIN";
private static final String[] AXIS_KEYS = {"immersion", "creativity", "controls", "completeness", "sound", "visual"};
private final GameReviewsMapper gameReviewsMapper;
private final GameReviewAxesMapper gameReviewAxesMapper;
private final GameReviewStatsMapper gameReviewStatsMapper;
private final GamesMapper gamesMapper;
private final PermissionGate permissionGate;
public GameReviewController(GameReviewsMapper gameReviewsMapper,
GameReviewAxesMapper gameReviewAxesMapper,
GameReviewStatsMapper gameReviewStatsMapper,
GamesMapper gamesMapper) {
GamesMapper gamesMapper,
PermissionGate permissionGate) {
this.gameReviewsMapper = gameReviewsMapper;
this.gameReviewAxesMapper = gameReviewAxesMapper;
this.gameReviewStatsMapper = gameReviewStatsMapper;
this.gamesMapper = gamesMapper;
this.permissionGate = permissionGate;
}
@GetMapping("/game/{id}/reviews")
@ -220,7 +223,7 @@ public class GameReviewController {
if (review == null || !Long.valueOf(id).equals(review.getGameId())) {
return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다.");
}
if (!canModify(userId, review.getUserId(), sessionRole(session))) {
if (!canModify(userId, review.getUserId(), session)) {
return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다.");
}
@ -286,7 +289,7 @@ public class GameReviewController {
if (review == null || !Long.valueOf(id).equals(review.getGameId())) {
return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다.");
}
if (!canModify(userId, review.getUserId(), sessionRole(session))) {
if (!canModify(userId, review.getUserId(), session)) {
return response(HttpStatus.FORBIDDEN, "작성자만 삭제할 수 있습니다.");
}
@ -415,12 +418,8 @@ public class GameReviewController {
return value;
}
private boolean isOperator(String role) {
return ROLE_ADMIN.equals(role);
}
private boolean canModify(Long currentUserId, Long authorUserId, String role) {
return (authorUserId != null && authorUserId.equals(currentUserId)) || isOperator(role);
private boolean canModify(Long currentUserId, Long authorUserId, HttpSession session) {
return (authorUserId != null && authorUserId.equals(currentUserId)) || permissionGate.canModerate(session);
}
private Long sessionUserId(HttpSession session) {
@ -441,14 +440,6 @@ public class GameReviewController {
return null;
}
private String sessionRole(HttpSession session) {
if (session == null) {
return null;
}
Object role = session.getAttribute("role");
return role instanceof String text ? text : null;
}
private String trimToNull(String value) {
if (value == null) {
return null;

View File

@ -3,6 +3,7 @@ package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.data.UserAuthIdentityData;
import com.pandoli365.bibimbap.data.UserData;
import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
import com.pandoli365.bibimbap.mapper.UsersMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import jakarta.servlet.http.HttpServletRequest;
@ -31,9 +32,11 @@ import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
@Controller
@ -52,14 +55,17 @@ public class UserController {
private final UsersMapper usersMapper;
private final UserAuthIdentitiesMapper userAuthIdentitiesMapper;
private final UserPermissionsMapper userPermissionsMapper;
private final SecureRandom secureRandom = new SecureRandom();
@Value("${app.upload.game-storage-path:src/main/resources/static}")
private String uploadStoragePath;
public UserController(UsersMapper usersMapper, UserAuthIdentitiesMapper userAuthIdentitiesMapper) {
public UserController(UsersMapper usersMapper, UserAuthIdentitiesMapper userAuthIdentitiesMapper,
UserPermissionsMapper userPermissionsMapper) {
this.usersMapper = usersMapper;
this.userAuthIdentitiesMapper = userAuthIdentitiesMapper;
this.userPermissionsMapper = userPermissionsMapper;
}
@PostMapping("/signup")
@ -522,6 +528,12 @@ public class UserController {
account.put("authIdentityId", identity.getId());
account.put("lastLoginAt", user.getLastLoginAt());
session.setAttribute("account", account);
// RBAC 권한 스냅샷 (결정4 세션 캐시. 인터셉터/게이트가 epoch 대조로 갱신)
Set<String> permissions = new HashSet<>(userPermissionsMapper.listKeys(user.getId()));
session.setAttribute("permissions", permissions);
long permsEpoch = user.getPermissionsEpoch() != null ? user.getPermissionsEpoch() : 0L;
session.setAttribute("permsEpoch", permsEpoch);
}
private String normalizeEmail(String email) {

View File

@ -0,0 +1,43 @@
package com.pandoli365.bibimbap.data;
import java.util.List;
public class OperatorView {
private Long userId;
private String displayName;
private String role;
private List<String> permissionKeys;
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public List<String> getPermissionKeys() {
return permissionKeys;
}
public void setPermissionKeys(List<String> permissionKeys) {
this.permissionKeys = permissionKeys;
}
}

View File

@ -0,0 +1,32 @@
package com.pandoli365.bibimbap.data;
public class PermissionData {
private String permissionKey;
private String displayName;
private Boolean isActive;
public String getPermissionKey() {
return permissionKey;
}
public void setPermissionKey(String permissionKey) {
this.permissionKey = permissionKey;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@ -9,6 +9,7 @@ public class UserData {
private String canonicalEmail;
private String avatarUrl;
private String role;
private Long permissionsEpoch;
private String status;
private OffsetDateTime lastLoginAt;
private OffsetDateTime createdAt;
@ -85,4 +86,12 @@ public class UserData {
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public Long getPermissionsEpoch() {
return permissionsEpoch;
}
public void setPermissionsEpoch(Long permissionsEpoch) {
this.permissionsEpoch = permissionsEpoch;
}
}

View File

@ -0,0 +1,40 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.PermissionData;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface PermissionsMapper {
@Insert("""
INSERT INTO permissions (permission_key, display_name, is_active)
VALUES (#{permissionKey}, #{displayName}, true)
ON CONFLICT (permission_key) DO UPDATE
SET display_name = EXCLUDED.display_name,
is_active = true
""")
int upsert(@Param("permissionKey") String permissionKey, @Param("displayName") String displayName);
@Select("""
SELECT
permission_key AS "permissionKey",
display_name AS "displayName",
is_active AS "isActive"
FROM permissions
WHERE is_active = true
ORDER BY id
""")
List<PermissionData> listActive();
@Select("""
SELECT permission_key
FROM permissions
WHERE is_active = true
""")
List<String> listActiveKeys();
}

View File

@ -0,0 +1,18 @@
package com.pandoli365.bibimbap.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface RbacAuditMapper {
@Insert("""
INSERT INTO rbac_audit_log (actor_id, target_id, action, permission_key)
VALUES (#{actorId}, #{targetId}, #{action}, #{permissionKey})
""")
int insert(@Param("actorId") long actorId,
@Param("targetId") long targetId,
@Param("action") String action,
@Param("permissionKey") String permissionKey);
}

View File

@ -0,0 +1,49 @@
package com.pandoli365.bibimbap.mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface UserPermissionsMapper {
@Select("""
SELECT permission_key
FROM user_permissions
WHERE user_id = #{userId}
""")
List<String> listKeys(long userId);
@Select("""
SELECT EXISTS(
SELECT 1
FROM user_permissions
WHERE user_id = #{userId}
AND permission_key = #{permissionKey}
)
""")
boolean exists(@Param("userId") long userId, @Param("permissionKey") String permissionKey);
@Insert("""
INSERT INTO user_permissions (user_id, permission_key, granted_by)
VALUES (#{userId}, #{permissionKey}, #{grantedBy})
""")
int insert(@Param("userId") long userId, @Param("permissionKey") String permissionKey, @Param("grantedBy") long grantedBy);
@Delete("""
DELETE FROM user_permissions
WHERE user_id = #{userId}
AND permission_key = #{permissionKey}
""")
int delete(@Param("userId") long userId, @Param("permissionKey") String permissionKey);
@Delete("""
DELETE FROM user_permissions
WHERE user_id = #{userId}
""")
int deleteAllByUser(long userId);
}

View File

@ -1,12 +1,16 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.OperatorView;
import com.pandoli365.bibimbap.data.UserData;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface UsersMapper {
@ -61,4 +65,41 @@ public interface UsersMapper {
AND is_delete IS NOT TRUE
""")
int updateUser(UserData user);
@Select("""
SELECT permissions_epoch
FROM users
WHERE id = #{userId}
AND is_delete IS NOT TRUE
""")
long getPermissionsEpoch(@Param("userId") long userId);
@Update("""
UPDATE users
SET permissions_epoch = permissions_epoch + 1,
updated_at = now()
WHERE id = #{userId}
AND is_delete IS NOT TRUE
""")
int bumpPermissionsEpoch(@Param("userId") long userId);
@Update("""
UPDATE users
SET role = #{role},
updated_at = now()
WHERE id = #{userId}
AND is_delete IS NOT TRUE
""")
int updateRole(@Param("userId") long userId, @Param("role") String role);
@Select("""
SELECT id AS "userId",
display_name AS "displayName",
role
FROM users
WHERE role IN ('ADMIN', 'SUBADMIN')
AND is_delete IS NOT TRUE
ORDER BY role, id
""")
List<OperatorView> listOperators();
}

View File

@ -0,0 +1,105 @@
package com.pandoli365.bibimbap.security;
import com.pandoli365.bibimbap.data.UserData;
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
import com.pandoli365.bibimbap.mapper.UsersMapper;
import jakarta.servlet.http.HttpSession;
import java.util.HashSet;
import java.util.Set;
import org.springframework.stereotype.Component;
@Component
public class PermissionGate {
private final UsersMapper usersMapper;
private final UserPermissionsMapper userPermissionsMapper;
public PermissionGate(UsersMapper usersMapper, UserPermissionsMapper userPermissionsMapper) {
this.usersMapper = usersMapper;
this.userPermissionsMapper = userPermissionsMapper;
}
public boolean has(HttpSession session, String permissionKey) {
Long userId = sessionUserId(session);
if (userId == null) {
return false;
}
refreshIfStale(session, userId);
String role = (String) session.getAttribute("role");
if (Roles.ADMIN.equals(role)) {
return true;
}
if (Roles.SUBADMIN.equals(role)) {
Object p = session.getAttribute("permissions");
return p instanceof Set<?> set && set.contains(permissionKey);
}
return false;
}
public boolean canModerate(HttpSession session) {
return has(session, PermissionKeys.CONTENT_MODERATE.name());
}
/**
* 미인증 vs 미인가 분기용(401 vs 403). 세션에 userId 있으면 인증됨.
*/
public boolean isAuthenticated(HttpSession session) {
return sessionUserId(session) != null;
}
/**
* 콘솔(/admin/**) ADMIN 전용 게이트. epoch 대조 현재 role==ADMIN 통과.
* 권한 기반 has 달리 SUBADMIN 어떤 키를 보유해도 통과시키지 않는다.
*/
public boolean isAdmin(HttpSession session) {
Long userId = sessionUserId(session);
if (userId == null) {
return false;
}
refreshIfStale(session, userId);
return Roles.ADMIN.equals(session.getAttribute("role"));
}
public boolean require(HttpSession session, String permissionKey) {
return has(session, permissionKey);
}
private Long sessionUserId(HttpSession session) {
if (session == null) {
return null;
}
Object attr = session.getAttribute("userId");
if (attr instanceof Number number) {
return number.longValue();
}
if (attr instanceof String s) {
try {
return Long.parseLong(s);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
private void refreshIfStale(HttpSession session, long userId) {
long dbEpoch = usersMapper.getPermissionsEpoch(userId);
Object epochAttr = session.getAttribute("permsEpoch");
long sessionEpoch = epochAttr instanceof Long l ? l : Long.MIN_VALUE;
if (dbEpoch != sessionEpoch) {
UserData u = usersMapper.getUser(userId);
if (u == null) {
session.removeAttribute("role");
session.setAttribute("permissions", new HashSet<String>());
session.setAttribute("permsEpoch", dbEpoch);
return;
}
session.setAttribute("role", u.getRole());
session.setAttribute("permissions", new HashSet<>(userPermissionsMapper.listKeys(userId)));
session.setAttribute("permsEpoch", dbEpoch);
}
}
}

View File

@ -0,0 +1,29 @@
package com.pandoli365.bibimbap.security;
public enum PermissionKeys {
GAME_JAM_MANAGE("게임잼 관리"),
POST_WRITE("포스팅 작성"),
CONTENT_MODERATE("콘텐츠 모더레이션");
private final String displayName;
PermissionKeys(String displayName) {
this.displayName = displayName;
}
public String displayName() {
return displayName;
}
public static boolean isValid(String key) {
if (key == null) {
return false;
}
try {
valueOf(key);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
}

View File

@ -0,0 +1,47 @@
package com.pandoli365.bibimbap.security;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class RbacInterceptor implements HandlerInterceptor {
private final PermissionGate permissionGate;
public RbacInterceptor(PermissionGate permissionGate) {
this.permissionGate = permissionGate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws IOException {
HttpSession session = request.getSession(false);
boolean page = request.getRequestURI().endsWith("/admin/console");
if (!permissionGate.isAuthenticated(session)) {
if (page) {
response.sendRedirect(request.getContextPath() + "/login");
return false;
}
writeJson(response, 401, "로그인이 필요합니다.");
return false;
}
if (!permissionGate.isAdmin(session)) {
writeJson(response, 403, "권한이 없습니다.");
return false;
}
return true;
}
private void writeJson(HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"status\":" + status + ",\"message\":\"" + message + "\"}");
}
}

View File

@ -0,0 +1,11 @@
package com.pandoli365.bibimbap.security;
public final class Roles {
public static final String ADMIN = "ADMIN";
public static final String SUBADMIN = "SUBADMIN";
public static final String USER = "USER";
private Roles() {
}
}

View File

@ -0,0 +1,487 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
<%@ page import="com.pandoli365.bibimbap.data.OperatorView" %>
<%@ page import="com.pandoli365.bibimbap.data.PermissionData" %>
<%@ page import="org.springframework.web.util.HtmlUtils" %>
<%@ page import="java.util.Collections" %>
<%@ page import="java.util.List" %>
<%
String ctx = request.getContextPath();
Object rawOperators = request.getAttribute("operators");
List<OperatorView> operators = rawOperators instanceof List<?> ? (List<OperatorView>) rawOperators : Collections.<OperatorView>emptyList();
Object rawCatalog = request.getAttribute("catalog");
List<PermissionData> catalog = rawCatalog instanceof List<?> ? (List<PermissionData>) rawCatalog : Collections.<PermissionData>emptyList();
Object rawCsrf = request.getAttribute("csrfToken");
String csrfToken = rawCsrf == null ? "" : String.valueOf(rawCsrf);
String csrfTokenHtml = HtmlUtils.htmlEscape(csrfToken);
// JS 문자열 컨텍스트용: 따옴표/역슬래시/스크립트 종료 시퀀스 차단
String csrfTokenJs = csrfToken
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\"", "\\\"")
.replace("<", "\\u003C")
.replace(">", "\\u003E")
.replace("\r", "")
.replace("\n", "");
%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="_csrf" content="<%= csrfTokenHtml %>">
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
<title>관리자 콘솔 | bibimbap</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--accent-soft: rgba(232, 165, 75, 0.16);
--border: rgba(0, 0, 0, 0.08);
--shadow: rgba(0, 0, 0, 0.06);
--field-bg: #fff;
--button-text: #1a1a1a;
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--shadow: rgba(0, 0, 0, 0.35);
--field-bg: #181818;
--button-text: #1a1a1a;
}
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--surface);
color: var(--text);
}
.admin-page {
max-width: 72rem;
margin: 0 auto;
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
}
.admin-hero {
margin-bottom: 1.5rem;
}
.admin-hero__eyebrow {
margin: 0 0 0.35rem;
color: var(--accent);
font-size: 0.75rem;
font-weight: 900;
}
.admin-hero h1 {
margin: 0;
font-size: 1.9rem;
line-height: 1.2;
letter-spacing: 0;
}
.admin-hero p {
margin: 0.45rem 0 0;
color: var(--text-muted);
line-height: 1.6;
}
.admin-section {
margin-bottom: 2rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
box-shadow: 0 2px 8px var(--shadow);
}
.admin-section h2 {
margin: 0 0 1rem;
font-size: 1.15rem;
letter-spacing: 0;
}
.admin-appoint {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: flex-end;
}
.admin-field {
display: grid;
gap: 0.375rem;
}
.admin-field label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--text);
}
.admin-field input {
height: 3rem;
min-width: 16rem;
box-sizing: border-box;
padding: 0 0.875rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--field-bg);
color: var(--text);
font-size: 1rem;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.admin-table th,
.admin-table td {
padding: 0.65rem 0.5rem;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
.admin-table th {
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 800;
}
.admin-perm-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
}
.admin-perm-tags li {
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.75rem;
font-weight: 800;
}
.admin-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.admin-btn {
min-height: 2.25rem;
padding: 0 0.85rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--card-bg);
color: var(--text);
font: inherit;
font-size: 0.8125rem;
font-weight: 800;
cursor: pointer;
}
.admin-btn:hover {
border-color: rgba(232, 165, 75, 0.45);
}
.admin-btn--primary {
border-color: transparent;
background: var(--accent);
color: var(--button-text);
}
.admin-btn--danger {
border-color: rgba(200, 60, 60, 0.45);
color: #c83c3c;
}
.admin-muted {
color: var(--text-muted);
font-size: 0.8125rem;
}
.admin-catalog {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.5rem;
}
.admin-catalog li {
display: flex;
gap: 0.75rem;
align-items: baseline;
padding: 0.5rem 0.65rem;
border: 1px solid var(--border);
border-radius: 10px;
}
.admin-catalog code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8125rem;
color: var(--accent);
}
.admin-catalog .admin-inactive {
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 800;
}
</style>
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="admin-page">
<section class="admin-hero" aria-labelledby="admin-title">
<p class="admin-hero__eyebrow">ADMIN CONSOLE</p>
<h1 id="admin-title">관리자 콘솔</h1>
<p>운영진 임명, 권한 토글, 강등을 관리합니다. 실제 권한 검증은 서버에서 수행됩니다.</p>
</section>
<section class="admin-section" aria-labelledby="admin-appoint-title">
<h2 id="admin-appoint-title">SUBADMIN 임명</h2>
<form class="admin-appoint" id="admin-appoint-form" autocomplete="off">
<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>" />
<div class="admin-field">
<label for="admin-appoint-user-id">사용자 ID</label>
<input type="text" id="admin-appoint-user-id" name="userId" inputmode="numeric" placeholder="대상 사용자 ID" required />
</div>
<button class="admin-btn admin-btn--primary" type="submit">SUBADMIN 임명</button>
</form>
</section>
<section class="admin-section" aria-labelledby="admin-operators-title">
<h2 id="admin-operators-title">운영진 목록</h2>
<table class="admin-table">
<thead>
<tr>
<th scope="col">표시 이름</th>
<th scope="col">역할</th>
<th scope="col">보유 권한</th>
<th scope="col">액션</th>
</tr>
</thead>
<tbody>
<%
for (OperatorView op : operators) {
if (op == null || op.getUserId() == null) {
continue;
}
String opUserId = String.valueOf(op.getUserId());
String opUserIdAttr = HtmlUtils.htmlEscape(opUserId);
String opDisplayName = HtmlUtils.htmlEscape(op.getDisplayName() == null || op.getDisplayName().isBlank() ? "(이름 없음)" : op.getDisplayName());
String rawRole = op.getRole() == null ? "" : op.getRole();
String opRole = HtmlUtils.htmlEscape(rawRole.isBlank() ? "(역할 없음)" : rawRole);
boolean isSubadmin = "SUBADMIN".equals(rawRole);
boolean isAdmin = "ADMIN".equals(rawRole);
List<String> permKeys = op.getPermissionKeys() == null ? Collections.<String>emptyList() : op.getPermissionKeys();
%>
<tr>
<td><%= opDisplayName %></td>
<td><%= opRole %></td>
<td>
<%
if (permKeys.isEmpty()) {
%>
<span class="admin-muted">(없음)</span>
<%
} else {
%>
<ul class="admin-perm-tags">
<%
for (String key : permKeys) {
if (key == null) {
continue;
}
%>
<li><%= HtmlUtils.htmlEscape(key) %></li>
<%
}
%>
</ul>
<%
}
%>
</td>
<td>
<%
if (isAdmin) {
%>
<span class="admin-muted">부트스트랩 관리자 (변경 불가)</span>
<%
} else if (isSubadmin) {
%>
<div class="admin-actions">
<%
for (PermissionData p : catalog) {
if (p == null || p.getPermissionKey() == null) {
continue;
}
String permKey = p.getPermissionKey();
String permKeyAttr = HtmlUtils.htmlEscape(permKey);
String permLabel = HtmlUtils.htmlEscape(p.getDisplayName() == null || p.getDisplayName().isBlank() ? permKey : p.getDisplayName());
boolean held = permKeys.contains(permKey);
%>
<button class="admin-btn" type="button"
data-action="toggle"
data-user-id="<%= opUserIdAttr %>"
data-permission-key="<%= permKeyAttr %>">
<%= held ? "해제: " : "부여: " %><%= permLabel %>
</button>
<%
}
%>
<button class="admin-btn admin-btn--danger" type="button"
data-action="demote"
data-user-id="<%= opUserIdAttr %>">강등</button>
</div>
<%
} else {
%>
<span class="admin-muted">-</span>
<%
}
%>
</td>
</tr>
<%
}
if (operators.isEmpty()) {
%>
<tr>
<td colspan="4"><span class="admin-muted">등록된 운영진이 없습니다.</span></td>
</tr>
<%
}
%>
</tbody>
</table>
</section>
<section class="admin-section" aria-labelledby="admin-catalog-title">
<h2 id="admin-catalog-title">권한 카탈로그</h2>
<%
if (catalog.isEmpty()) {
%>
<span class="admin-muted">등록된 권한이 없습니다.</span>
<%
} else {
%>
<ul class="admin-catalog">
<%
for (PermissionData p : catalog) {
if (p == null || p.getPermissionKey() == null) {
continue;
}
String permKey = HtmlUtils.htmlEscape(p.getPermissionKey());
String permLabel = HtmlUtils.htmlEscape(p.getDisplayName() == null || p.getDisplayName().isBlank() ? p.getPermissionKey() : p.getDisplayName());
boolean active = Boolean.TRUE.equals(p.getIsActive());
%>
<li>
<code><%= permKey %></code>
<span><%= permLabel %></span>
<%
if (!active) {
%>
<span class="admin-inactive">비활성</span>
<%
}
%>
</li>
<%
}
%>
</ul>
<%
}
%>
</section>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
<script>
(function () {
var ctx = '<%= ctx %>';
var CSRF_TOKEN = '<%= csrfTokenJs %>';
function notify(message) {
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({ title: '관리자 콘솔', message: message });
return;
}
alert(message);
}
function post(url) {
return fetch(url, {
method: 'POST',
headers: {
'X-CSRF-Token': CSRF_TOKEN,
'Accept': 'application/json'
}
});
}
function handleResult(res) {
if (res.ok) {
window.location.reload();
return;
}
notify('요청을 처리하지 못했습니다. (상태 ' + res.status + ')');
}
function handleError() {
notify('요청 중 오류가 발생했습니다.');
}
function appoint(userId) {
if (!userId) {
notify('사용자 ID를 입력해 주세요.');
return;
}
post(ctx + '/admin/users/' + encodeURIComponent(userId) + '/appoint')
.then(handleResult)
.catch(handleError);
}
function toggle(userId, key) {
post(ctx + '/admin/users/' + encodeURIComponent(userId) + '/permissions/' + encodeURIComponent(key) + '/toggle')
.then(handleResult)
.catch(handleError);
}
function demote(userId) {
post(ctx + '/admin/users/' + encodeURIComponent(userId) + '/demote')
.then(handleResult)
.catch(handleError);
}
// operators 조회 (GET) — 외부에서 재사용 가능하도록 노출
function fetchOperators() {
return fetch(ctx + '/admin/operators', {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
}
window.BibimbapAdminConsole = { fetchOperators: fetchOperators };
var appointForm = document.getElementById('admin-appoint-form');
if (appointForm) {
appointForm.addEventListener('submit', function (ev) {
ev.preventDefault();
var input = document.getElementById('admin-appoint-user-id');
appoint(input ? input.value.trim() : '');
});
}
// data-* 속성 + 위임 핸들러 (inline 핸들러에 사용자 데이터 삽입 금지)
document.addEventListener('click', function (ev) {
var btn = ev.target.closest('[data-action]');
if (!btn) {
return;
}
var action = btn.getAttribute('data-action');
var userId = btn.getAttribute('data-user-id');
if (action === 'toggle') {
toggle(userId, btn.getAttribute('data-permission-key'));
} else if (action === 'demote') {
demote(userId);
}
});
})();
</script>
</body>
</html>

View File

@ -5,9 +5,13 @@ import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.PermissionsMapper;
import com.pandoli365.bibimbap.mapper.RbacAuditMapper;
import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
import com.pandoli365.bibimbap.mapper.UsersMapper;
import com.pandoli365.bibimbap.security.PermissionGate;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
@ -44,6 +48,18 @@ class BibimbapApplicationTests {
@MockBean
private UsersMapper usersMapper;
@MockBean
private PermissionsMapper permissionsMapper;
@MockBean
private UserPermissionsMapper userPermissionsMapper;
@MockBean
private RbacAuditMapper rbacAuditMapper;
@MockBean
private PermissionGate permissionGate;
@Test
void contextLoads() {
}

View File

@ -0,0 +1,291 @@
package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.OperatorView;
import com.pandoli365.bibimbap.data.UserData;
import com.pandoli365.bibimbap.mapper.PermissionsMapper;
import com.pandoli365.bibimbap.mapper.RbacAuditMapper;
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
import com.pandoli365.bibimbap.mapper.UsersMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.security.PermissionKeys;
import com.pandoli365.bibimbap.security.Roles;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AdminConsoleControllerTest {
private static final long ACTOR_ID = 99L;
private static final long TARGET_ID = 42L;
private static final String POST_WRITE = PermissionKeys.POST_WRITE.name();
@Mock
private UsersMapper usersMapper;
@Mock
private UserPermissionsMapper userPermissionsMapper;
@Mock
private RbacAuditMapper auditMapper;
@Mock
private PermissionsMapper permissionsMapper;
// ---- appoint ----
@Test
void appointPromotesUserToSubadmin() {
AdminConsoleController controller = controller();
MockHttpSession session = adminSession(ACTOR_ID);
MockHttpServletRequest request = csrfPost(session);
when(usersMapper.getUser(TARGET_ID)).thenReturn(user(TARGET_ID, Roles.USER));
ResponseEntity<Map<String, Object>> response = controller.appoint(TARGET_ID, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("role", Roles.SUBADMIN);
verify(usersMapper).updateRole(TARGET_ID, Roles.SUBADMIN);
verify(usersMapper).bumpPermissionsEpoch(TARGET_ID);
verify(auditMapper).insert(eq(ACTOR_ID), eq(TARGET_ID), eq("APPOINT"), isNull());
}
@Test
void appointRejectsMissingCsrf() {
AdminConsoleController controller = controller();
MockHttpSession session = adminSession(ACTOR_ID);
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response = controller.appoint(TARGET_ID, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(usersMapper, never()).updateRole(anyLong(), any());
}
@Test
void appointReturns404WhenTargetMissing() {
AdminConsoleController controller = controller();
MockHttpSession session = adminSession(ACTOR_ID);
MockHttpServletRequest request = csrfPost(session);
when(usersMapper.getUser(99L)).thenReturn(null);
ResponseEntity<Map<String, Object>> response = controller.appoint(99L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
verify(usersMapper, never()).updateRole(anyLong(), any());
}
@Test
void appointReturns409WhenAlreadyOperator() {
AdminConsoleController controller = controller();
MockHttpSession session = adminSession(ACTOR_ID);
MockHttpServletRequest request = csrfPost(session);
when(usersMapper.getUser(TARGET_ID)).thenReturn(user(TARGET_ID, Roles.SUBADMIN));
ResponseEntity<Map<String, Object>> response = controller.appoint(TARGET_ID, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
verify(usersMapper, never()).updateRole(anyLong(), any());
}
// ---- togglePermission ----
@Test
void toggleGrantsWhenAbsent() {
AdminConsoleController controller = controller();
MockHttpSession session = adminSession(ACTOR_ID);
MockHttpServletRequest request = csrfPost(session);
when(usersMapper.getUser(TARGET_ID)).thenReturn(user(TARGET_ID, Roles.SUBADMIN));
when(userPermissionsMapper.exists(TARGET_ID, POST_WRITE)).thenReturn(false);
ResponseEntity<Map<String, Object>> response =
controller.togglePermission(TARGET_ID, POST_WRITE, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("granted", true);
verify(userPermissionsMapper).insert(eq(TARGET_ID), eq(POST_WRITE), anyLong());
verify(usersMapper).bumpPermissionsEpoch(TARGET_ID);
verify(auditMapper).insert(eq(ACTOR_ID), eq(TARGET_ID), eq("GRANT"), eq(POST_WRITE));
}
@Test
void toggleRevokesWhenPresent() {
AdminConsoleController controller = controller();
MockHttpSession session = adminSession(ACTOR_ID);
MockHttpServletRequest request = csrfPost(session);
when(usersMapper.getUser(TARGET_ID)).thenReturn(user(TARGET_ID, Roles.SUBADMIN));
when(userPermissionsMapper.exists(TARGET_ID, POST_WRITE)).thenReturn(true);
ResponseEntity<Map<String, Object>> response =
controller.togglePermission(TARGET_ID, POST_WRITE, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("granted", false);
verify(userPermissionsMapper).delete(TARGET_ID, POST_WRITE);
verify(usersMapper).bumpPermissionsEpoch(TARGET_ID);
verify(auditMapper).insert(eq(ACTOR_ID), eq(TARGET_ID), eq("REVOKE"), eq(POST_WRITE));
}
@Test
void toggleRejectsMissingCsrf() {
AdminConsoleController controller = controller();
MockHttpSession session = adminSession(ACTOR_ID);
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.togglePermission(TARGET_ID, POST_WRITE, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(userPermissionsMapper, never()).insert(anyLong(), any(), anyLong());
verify(userPermissionsMapper, never()).delete(anyLong(), any());
}
@Test
void toggleReturns404WhenKeyInvalid() {
AdminConsoleController controller = controller();
MockHttpSession session = adminSession(ACTOR_ID);
MockHttpServletRequest request = csrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.togglePermission(TARGET_ID, "NOPE", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void toggleReturns422WhenTargetNotSubadmin() {
AdminConsoleController controller = controller();
MockHttpSession session = adminSession(ACTOR_ID);
MockHttpServletRequest request = csrfPost(session);
when(usersMapper.getUser(TARGET_ID)).thenReturn(user(TARGET_ID, Roles.USER));
ResponseEntity<Map<String, Object>> response =
controller.togglePermission(TARGET_ID, POST_WRITE, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
verify(userPermissionsMapper, never()).insert(anyLong(), any(), anyLong());
verify(userPermissionsMapper, never()).delete(anyLong(), any());
}
// ---- demote ----
@Test
void demoteRevokesAllAndSetsUser() {
AdminConsoleController controller = controller();
MockHttpSession session = adminSession(ACTOR_ID);
MockHttpServletRequest request = csrfPost(session);
when(usersMapper.getUser(TARGET_ID)).thenReturn(user(TARGET_ID, Roles.SUBADMIN));
when(userPermissionsMapper.deleteAllByUser(TARGET_ID)).thenReturn(3);
ResponseEntity<Map<String, Object>> response = controller.demote(TARGET_ID, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("role", Roles.USER);
assertThat(response.getBody()).containsEntry("revokedCount", 3);
verify(userPermissionsMapper).deleteAllByUser(TARGET_ID);
verify(usersMapper).updateRole(TARGET_ID, Roles.USER);
verify(usersMapper).bumpPermissionsEpoch(TARGET_ID);
verify(auditMapper).insert(eq(ACTOR_ID), eq(TARGET_ID), eq("DEMOTE"), isNull());
}
@Test
void demoteRejectsMissingCsrf() {
AdminConsoleController controller = controller();
MockHttpSession session = adminSession(ACTOR_ID);
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response = controller.demote(TARGET_ID, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(userPermissionsMapper, never()).deleteAllByUser(anyLong());
}
@Test
void demoteReturns422WhenNotSubadmin() {
AdminConsoleController controller = controller();
MockHttpSession session = adminSession(ACTOR_ID);
MockHttpServletRequest request = csrfPost(session);
when(usersMapper.getUser(TARGET_ID)).thenReturn(user(TARGET_ID, Roles.USER));
ResponseEntity<Map<String, Object>> response = controller.demote(TARGET_ID, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
verify(userPermissionsMapper, never()).deleteAllByUser(anyLong());
}
// ---- operators ----
@Test
void operatorsReturnsListWithPermissions() {
AdminConsoleController controller = controller();
OperatorView op = new OperatorView();
op.setUserId(TARGET_ID);
op.setRole(Roles.SUBADMIN);
op.setDisplayName("운영");
when(usersMapper.listOperators()).thenReturn(List.of(op));
when(userPermissionsMapper.listKeys(TARGET_ID)).thenReturn(List.of(POST_WRITE));
ResponseEntity<Map<String, Object>> response = controller.operators();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
List<Map<String, Object>> operators = (List<Map<String, Object>>) response.getBody().get("operators");
assertThat(operators).hasSize(1);
assertThat(operators.get(0)).containsEntry("userId", TARGET_ID);
assertThat(operators.get(0)).containsEntry("role", Roles.SUBADMIN);
assertThat(operators.get(0)).containsEntry("displayName", "운영");
assertThat(operators.get(0)).containsEntry("permissions", List.of(POST_WRITE));
}
// ---- helpers ----
private AdminConsoleController controller() {
return new AdminConsoleController(usersMapper, userPermissionsMapper, auditMapper, permissionsMapper);
}
private MockHttpSession adminSession(long actorId) {
MockHttpSession session = new MockHttpSession();
session.setAttribute("userId", actorId);
session.setAttribute("role", Roles.ADMIN);
CsrfTokens.getOrCreate(session);
return session;
}
private MockHttpServletRequest csrfPost(MockHttpSession session) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSession(session);
request.addHeader(CsrfTokens.HEADER_NAME, (String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE));
return request;
}
private MockHttpServletRequest noCsrfPost(MockHttpSession session) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSession(session);
return request;
}
private UserData user(long id, String role) {
UserData data = new UserData();
data.setId(id);
data.setRole(role);
data.setDisplayName("대상");
return data;
}
}

View File

@ -5,6 +5,7 @@ import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.security.PermissionGate;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@ -39,6 +40,9 @@ class GameCommentControllerTest {
@Mock
private GamesMapper gamesMapper;
@Mock
private PermissionGate permissionGate;
// ---- AC-1: 댓글 CRUD ----
@Test
@ -134,6 +138,22 @@ class GameCommentControllerTest {
MockHttpSession session = loginSession(99L, "ADMIN", "운영자");
MockHttpServletRequest request = csrfPost(session);
when(gameCommentsMapper.getGameComment(5L)).thenReturn(comment(5L, 1L, 7L));
when(permissionGate.canModerate(any())).thenReturn(true);
ResponseEntity<Map<String, Object>> response =
controller.deleteComment(1L, 5L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameCommentsMapper).softDeleteGameComment(5L);
}
@Test
void deleteCommentBySubadminModeratorSucceeds() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(50L, "SUBADMIN", "부운영자");
MockHttpServletRequest request = csrfPost(session);
when(gameCommentsMapper.getGameComment(5L)).thenReturn(comment(5L, 1L, 7L));
when(permissionGate.canModerate(any())).thenReturn(true);
ResponseEntity<Map<String, Object>> response =
controller.deleteComment(1L, 5L, request, session);
@ -370,7 +390,7 @@ class GameCommentControllerTest {
// ---- helpers ----
private GameCommentController controller() {
return new GameCommentController(gameCommentsMapper, gamesMapper);
return new GameCommentController(gameCommentsMapper, gamesMapper, permissionGate);
}
private GameData game(long id) {

View File

@ -8,6 +8,7 @@ import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.security.PermissionGate;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@ -50,6 +51,9 @@ class GameReviewControllerTest {
@Mock
private GamesMapper gamesMapper;
@Mock
private PermissionGate permissionGate;
// 6축 유효 입력(immersion,creativity,controls,completeness,sound,visual).
private static final String[] AXES_OK = {"4", "5", "3", "4", "2", "5"};
@ -170,6 +174,24 @@ class GameReviewControllerTest {
MockHttpSession session = loginSession(99L, "ADMIN");
MockHttpServletRequest request = csrfPost(session);
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
// 작성자 타인(99 != 7) 모더레이션 게이트 경유. ADMIN 모더레이터 통과.
when(permissionGate.canModerate(any())).thenReturn(true);
ResponseEntity<Map<String, Object>> response =
controller.deleteReview(1L, 50L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameReviewsMapper).softDeleteGameReview(50L);
}
@Test
void deleteReviewBySubadminModeratorSucceeds() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(50L, "SUBADMIN");
MockHttpServletRequest request = csrfPost(session);
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
// 작성자 타인(50 != 7) 게이트 계약으로 SUBADMIN 모더레이터 통과.
when(permissionGate.canModerate(any())).thenReturn(true);
ResponseEntity<Map<String, Object>> response =
controller.deleteReview(1L, 50L, request, session);
@ -422,7 +444,7 @@ class GameReviewControllerTest {
private GameReviewController controller() {
return new GameReviewController(
gameReviewsMapper, gameReviewAxesMapper, gameReviewStatsMapper, gamesMapper);
gameReviewsMapper, gameReviewAxesMapper, gameReviewStatsMapper, gamesMapper, permissionGate);
}
private GameData game(long id) {

View File

@ -32,6 +32,9 @@ class UserControllerCsrfTest {
@Mock
private UserAuthIdentitiesMapper userAuthIdentitiesMapper;
@Mock
private com.pandoli365.bibimbap.mapper.UserPermissionsMapper userPermissionsMapper;
@Test
void signupRejectsMissingCsrfBeforeMapperAccess() {
UserController controller = controller();
@ -154,7 +157,7 @@ class UserControllerCsrfTest {
}
private UserController controller() {
return new UserController(usersMapper, userAuthIdentitiesMapper);
return new UserController(usersMapper, userAuthIdentitiesMapper, userPermissionsMapper);
}
private MockHttpServletRequest jsonPost(String path) {

View File

@ -0,0 +1,149 @@
package com.pandoli365.bibimbap.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.pandoli365.bibimbap.data.UserData;
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
import com.pandoli365.bibimbap.mapper.UsersMapper;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpSession;
class PermissionGateTest {
private final UsersMapper usersMapper = mock(UsersMapper.class);
private final UserPermissionsMapper userPermissionsMapper = mock(UserPermissionsMapper.class);
private final PermissionGate gate = new PermissionGate(usersMapper, userPermissionsMapper);
@Test
void hasReturnsFalseWhenUnauthenticated() {
// given: userId 없는 세션
MockHttpSession session = session(null, null, null, null);
// when / then
assertThat(gate.has(session, PermissionKeys.POST_WRITE.name())).isFalse();
// 미인증이면 epoch 대조조차 하지 않는다 (mapper 미호출)
verify(usersMapper, never()).getPermissionsEpoch(anyLong());
}
@Test
void adminPassesAnyPermission() {
// given: ADMIN, epoch 정합 재로딩 없음
MockHttpSession session = session(1L, Roles.ADMIN, 0L, Set.of());
when(usersMapper.getPermissionsEpoch(1L)).thenReturn(0L);
// when / then: 전권
assertThat(gate.has(session, PermissionKeys.POST_WRITE.name())).isTrue();
assertThat(gate.canModerate(session)).isTrue();
assertThat(gate.isAdmin(session)).isTrue();
verify(userPermissionsMapper, never()).listKeys(anyLong());
}
@Test
void subadminPassesOnlyHeldKey() {
// given: SUBADMIN, POST_WRITE 보유, epoch 정합
MockHttpSession session =
session(2L, Roles.SUBADMIN, 5L, Set.of(PermissionKeys.POST_WRITE.name()));
when(usersMapper.getPermissionsEpoch(2L)).thenReturn(5L);
// when / then
assertThat(gate.has(session, PermissionKeys.POST_WRITE.name())).isTrue();
assertThat(gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())).isFalse();
assertThat(gate.isAdmin(session)).isFalse();
verify(userPermissionsMapper, never()).listKeys(anyLong());
}
@Test
void userPassesNothing() {
// given: 일반 USER, epoch 정합
MockHttpSession session = session(3L, Roles.USER, 0L, Set.of());
when(usersMapper.getPermissionsEpoch(3L)).thenReturn(0L);
// when / then
assertThat(gate.has(session, PermissionKeys.POST_WRITE.name())).isFalse();
assertThat(gate.isAdmin(session)).isFalse();
}
@Test
void epochMismatchReloadsPermissions() {
// given (AC-2 회수 즉시성): 세션 캐시엔 POST_WRITE 보유(epoch=1),
// 그러나 DB 에서 회수됨 epoch bump(2), listKeys 목록
MockHttpSession session =
session(4L, Roles.SUBADMIN, 1L, Set.of(PermissionKeys.POST_WRITE.name()));
when(usersMapper.getPermissionsEpoch(4L)).thenReturn(2L);
UserData reloaded = new UserData();
reloaded.setId(4L);
reloaded.setRole(Roles.SUBADMIN);
reloaded.setPermissionsEpoch(2L);
when(usersMapper.getUser(4L)).thenReturn(reloaded);
when(userPermissionsMapper.listKeys(4L)).thenReturn(List.of());
// when / then: 재로딩으로 회수 반영 캐시에 있던 키도 통과
assertThat(gate.has(session, PermissionKeys.POST_WRITE.name())).isFalse();
// 재로딩이 실제로 발생했는지
verify(userPermissionsMapper).listKeys(4L);
// 세션 epoch DB 값으로 갱신됐는지
assertThat(session.getAttribute("permsEpoch")).isEqualTo(2L);
// 권한 캐시가 목록으로 갱신됐는지
assertThat(session.getAttribute("permissions"))
.isInstanceOf(Set.class)
.satisfies(p -> assertThat((Set<?>) p).isEmpty());
}
@Test
void epochMismatchGrantsNewKey() {
// given (AC-1 대칭): 세션 캐시엔 미보유(epoch=1),
// DB 에서 부여됨 epoch bump(2), listKeys POST_WRITE 포함
MockHttpSession session = session(5L, Roles.SUBADMIN, 1L, Set.of());
when(usersMapper.getPermissionsEpoch(5L)).thenReturn(2L);
UserData reloaded = new UserData();
reloaded.setId(5L);
reloaded.setRole(Roles.SUBADMIN);
reloaded.setPermissionsEpoch(2L);
when(usersMapper.getUser(5L)).thenReturn(reloaded);
when(userPermissionsMapper.listKeys(5L))
.thenReturn(List.of(PermissionKeys.POST_WRITE.name()));
// when / then: 재로딩으로 부여 반영
assertThat(gate.has(session, PermissionKeys.POST_WRITE.name())).isTrue();
verify(userPermissionsMapper).listKeys(5L);
assertThat(session.getAttribute("permsEpoch")).isEqualTo(2L);
}
@Test
void isAuthenticatedReflectsSession() {
// given
MockHttpSession authed = session(6L, Roles.USER, 0L, Set.of());
MockHttpSession anonymous = session(null, null, null, null);
// when / then
assertThat(gate.isAuthenticated(authed)).isTrue();
assertThat(gate.isAuthenticated(anonymous)).isFalse();
}
/**
* 세션 빌더. null attr setAttribute 하지 않는다(특히 userId null 이면 미인증 세션).
*/
private MockHttpSession session(Long userId, String role, Long epoch, Set<String> perms) {
MockHttpSession session = new MockHttpSession();
if (userId != null) {
session.setAttribute("userId", userId);
}
if (role != null) {
session.setAttribute("role", role);
}
if (epoch != null) {
session.setAttribute("permsEpoch", epoch);
}
if (perms != null) {
session.setAttribute("permissions", perms);
}
return session;
}
}