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:
parent
e0eb8eae15
commit
941f9fb128
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
$$;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
$$;
|
||||
|
|
@ -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/**");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + "\"}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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() {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue