diff --git a/db/bootstrap-admin.sql b/db/bootstrap-admin.sql new file mode 100644 index 0000000..b95c1e4 --- /dev/null +++ b/db/bootstrap-admin.sql @@ -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; diff --git a/db/schema.sql b/db/schema.sql index 51253bc..9b4704b 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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 +$$; diff --git a/docs/rbac-ddl.sql b/docs/rbac-ddl.sql new file mode 100644 index 0000000..95c53d2 --- /dev/null +++ b/docs/rbac-ddl.sql @@ -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 +$$; diff --git a/src/main/java/com/pandoli365/bibimbap/config/InterceptorConfig.java b/src/main/java/com/pandoli365/bibimbap/config/InterceptorConfig.java new file mode 100644 index 0000000..ecf838f --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/config/InterceptorConfig.java @@ -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/**"); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/config/PermissionCatalogVerifier.java b/src/main/java/com/pandoli365/bibimbap/config/PermissionCatalogVerifier.java new file mode 100644 index 0000000..db694e6 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/config/PermissionCatalogVerifier.java @@ -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 codeKeys = Arrays.stream(PermissionKeys.values()) + .map(PermissionKeys::name) + .collect(Collectors.toSet()); + List 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); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/AdminConsoleController.java b/src/main/java/com/pandoli365/bibimbap/controller/AdminConsoleController.java new file mode 100644 index 0000000..206e70b --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/AdminConsoleController.java @@ -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> 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 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> 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 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> 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 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> operators() { + List ops = buildOperators(); + List> operatorList = new ArrayList<>(); + for (OperatorView op : ops) { + Map 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 body = new LinkedHashMap<>(); + body.put("status", HttpStatus.OK.value()); + body.put("operators", operatorList); + return ResponseEntity.ok(body); + } + + private List buildOperators() { + List 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> response(HttpStatus status, String message) { + Map body = new LinkedHashMap<>(); + body.put("status", status.value()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java index d84d04a..69f002d 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java @@ -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; diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java index 07d426f..be6ba28 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java @@ -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; diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java index 5d226f3..642aa57 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java @@ -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 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) { diff --git a/src/main/java/com/pandoli365/bibimbap/data/OperatorView.java b/src/main/java/com/pandoli365/bibimbap/data/OperatorView.java new file mode 100644 index 0000000..d2221d8 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/OperatorView.java @@ -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 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 getPermissionKeys() { + return permissionKeys; + } + + public void setPermissionKeys(List permissionKeys) { + this.permissionKeys = permissionKeys; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/PermissionData.java b/src/main/java/com/pandoli365/bibimbap/data/PermissionData.java new file mode 100644 index 0000000..b344083 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/PermissionData.java @@ -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; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/UserData.java b/src/main/java/com/pandoli365/bibimbap/data/UserData.java index f40ae2e..d95c607 100644 --- a/src/main/java/com/pandoli365/bibimbap/data/UserData.java +++ b/src/main/java/com/pandoli365/bibimbap/data/UserData.java @@ -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; + } } diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/PermissionsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/PermissionsMapper.java new file mode 100644 index 0000000..de0c6cc --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/PermissionsMapper.java @@ -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 listActive(); + + @Select(""" + SELECT permission_key + FROM permissions + WHERE is_active = true + """) + List listActiveKeys(); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/RbacAuditMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/RbacAuditMapper.java new file mode 100644 index 0000000..f65eaef --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/RbacAuditMapper.java @@ -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); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/UserPermissionsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/UserPermissionsMapper.java new file mode 100644 index 0000000..0bb3277 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/UserPermissionsMapper.java @@ -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 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); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java index c1a869b..a499f15 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java @@ -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 listOperators(); } diff --git a/src/main/java/com/pandoli365/bibimbap/security/PermissionGate.java b/src/main/java/com/pandoli365/bibimbap/security/PermissionGate.java new file mode 100644 index 0000000..51e9155 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/security/PermissionGate.java @@ -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()); + session.setAttribute("permsEpoch", dbEpoch); + return; + } + session.setAttribute("role", u.getRole()); + session.setAttribute("permissions", new HashSet<>(userPermissionsMapper.listKeys(userId))); + session.setAttribute("permsEpoch", dbEpoch); + } + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/security/PermissionKeys.java b/src/main/java/com/pandoli365/bibimbap/security/PermissionKeys.java new file mode 100644 index 0000000..d5e0885 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/security/PermissionKeys.java @@ -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; + } + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/security/RbacInterceptor.java b/src/main/java/com/pandoli365/bibimbap/security/RbacInterceptor.java new file mode 100644 index 0000000..7a7d014 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/security/RbacInterceptor.java @@ -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 + "\"}"); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/security/Roles.java b/src/main/java/com/pandoli365/bibimbap/security/Roles.java new file mode 100644 index 0000000..158bcec --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/security/Roles.java @@ -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() { + } +} diff --git a/src/main/webapp/WEB-INF/views/admin-console.jsp b/src/main/webapp/WEB-INF/views/admin-console.jsp new file mode 100644 index 0000000..556eacc --- /dev/null +++ b/src/main/webapp/WEB-INF/views/admin-console.jsp @@ -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 operators = rawOperators instanceof List ? (List) rawOperators : Collections.emptyList(); + + Object rawCatalog = request.getAttribute("catalog"); + List catalog = rawCatalog instanceof List ? (List) rawCatalog : Collections.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", ""); +%> + + + + + + + + 관리자 콘솔 | bibimbap + + + + +
+
+

ADMIN CONSOLE

+

관리자 콘솔

+

운영진 임명, 권한 토글, 강등을 관리합니다. 실제 권한 검증은 서버에서 수행됩니다.

+
+ +
+

SUBADMIN 임명

+
+ +
+ + +
+ +
+
+ +
+

운영진 목록

+ + + + + + + + + + + <% + 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 permKeys = op.getPermissionKeys() == null ? Collections.emptyList() : op.getPermissionKeys(); + %> + + + + + + + <% + } + if (operators.isEmpty()) { + %> + + + + <% + } + %> + +
표시 이름역할보유 권한액션
<%= opDisplayName %><%= opRole %> + <% + if (permKeys.isEmpty()) { + %> + (없음) + <% + } else { + %> +
    + <% + for (String key : permKeys) { + if (key == null) { + continue; + } + %> +
  • <%= HtmlUtils.htmlEscape(key) %>
  • + <% + } + %> +
+ <% + } + %> +
+ <% + if (isAdmin) { + %> + 부트스트랩 관리자 (변경 불가) + <% + } else if (isSubadmin) { + %> +
+ <% + 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); + %> + + <% + } + %> + +
+ <% + } else { + %> + - + <% + } + %> +
등록된 운영진이 없습니다.
+
+ +
+

권한 카탈로그

+ <% + if (catalog.isEmpty()) { + %> + 등록된 권한이 없습니다. + <% + } else { + %> +
    + <% + 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()); + %> +
  • + <%= permKey %> + <%= permLabel %> + <% + if (!active) { + %> + 비활성 + <% + } + %> +
  • + <% + } + %> +
+ <% + } + %> +
+
+ + + + diff --git a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java index 20d2f46..5146b96 100644 --- a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java +++ b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java @@ -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() { } diff --git a/src/test/java/com/pandoli365/bibimbap/controller/AdminConsoleControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/AdminConsoleControllerTest.java new file mode 100644 index 0000000..bafe4bc --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/controller/AdminConsoleControllerTest.java @@ -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> 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> 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> 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> 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> 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> 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> 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> 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> 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> 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> 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> 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> response = controller.operators(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + @SuppressWarnings("unchecked") + List> operators = (List>) 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; + } +} diff --git a/src/test/java/com/pandoli365/bibimbap/controller/api/GameCommentControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/api/GameCommentControllerTest.java index c2def0d..6647969 100644 --- a/src/test/java/com/pandoli365/bibimbap/controller/api/GameCommentControllerTest.java +++ b/src/test/java/com/pandoli365/bibimbap/controller/api/GameCommentControllerTest.java @@ -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> 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> 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) { diff --git a/src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java index 3a5d3cc..dcb2ee0 100644 --- a/src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java +++ b/src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java @@ -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> 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> 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) { diff --git a/src/test/java/com/pandoli365/bibimbap/controller/api/UserControllerCsrfTest.java b/src/test/java/com/pandoli365/bibimbap/controller/api/UserControllerCsrfTest.java index a13c63c..612bf0f 100644 --- a/src/test/java/com/pandoli365/bibimbap/controller/api/UserControllerCsrfTest.java +++ b/src/test/java/com/pandoli365/bibimbap/controller/api/UserControllerCsrfTest.java @@ -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) { diff --git a/src/test/java/com/pandoli365/bibimbap/security/PermissionGateTest.java b/src/test/java/com/pandoli365/bibimbap/security/PermissionGateTest.java new file mode 100644 index 0000000..7fe394c --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/security/PermissionGateTest.java @@ -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 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; + } +}