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";
|
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)
|
-- 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"
|
CREATE INDEX IF NOT EXISTS "idx_recruit_posts_participation_type"
|
||||||
ON "recruit_posts" ("participation_type")
|
ON "recruit_posts" ("participation_type")
|
||||||
WHERE "is_delete" = false AND "is_visible" = true;
|
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.GameCommentsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||||
|
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||||
import com.pandoli365.bibimbap.util.TextNormalizer;
|
import com.pandoli365.bibimbap.util.TextNormalizer;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpSession;
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
|
@ -28,14 +29,15 @@ public class GameCommentController {
|
||||||
|
|
||||||
private static final int CONTENT_MAX = 200;
|
private static final int CONTENT_MAX = 200;
|
||||||
private static final int PAGE_SIZE = 20;
|
private static final int PAGE_SIZE = 20;
|
||||||
private static final String ROLE_ADMIN = "ADMIN";
|
|
||||||
|
|
||||||
private final GameCommentsMapper gameCommentsMapper;
|
private final GameCommentsMapper gameCommentsMapper;
|
||||||
private final GamesMapper gamesMapper;
|
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.gameCommentsMapper = gameCommentsMapper;
|
||||||
this.gamesMapper = gamesMapper;
|
this.gamesMapper = gamesMapper;
|
||||||
|
this.permissionGate = permissionGate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/game/{id}/comments")
|
@GetMapping("/game/{id}/comments")
|
||||||
|
|
@ -129,7 +131,7 @@ public class GameCommentController {
|
||||||
if (comment == null || !Long.valueOf(id).equals(comment.getGameId())) {
|
if (comment == null || !Long.valueOf(id).equals(comment.getGameId())) {
|
||||||
return response(HttpStatus.NOT_FOUND, "덧글을 찾을 수 없습니다.");
|
return response(HttpStatus.NOT_FOUND, "덧글을 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
if (!canModify(userId, comment.getUserId(), sessionRole(session))) {
|
if (!canModify(userId, comment.getUserId(), session)) {
|
||||||
return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다.");
|
return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,7 +170,7 @@ public class GameCommentController {
|
||||||
if (comment == null || !Long.valueOf(id).equals(comment.getGameId())) {
|
if (comment == null || !Long.valueOf(id).equals(comment.getGameId())) {
|
||||||
return response(HttpStatus.NOT_FOUND, "덧글을 찾을 수 없습니다.");
|
return response(HttpStatus.NOT_FOUND, "덧글을 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
if (!canModify(userId, comment.getUserId(), sessionRole(session))) {
|
if (!canModify(userId, comment.getUserId(), session)) {
|
||||||
return response(HttpStatus.FORBIDDEN, "작성자만 삭제할 수 있습니다.");
|
return response(HttpStatus.FORBIDDEN, "작성자만 삭제할 수 있습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,12 +199,8 @@ public class GameCommentController {
|
||||||
return "newest".equals(sort) ? "newest" : "oldest";
|
return "newest".equals(sort) ? "newest" : "oldest";
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isOperator(String role) {
|
private boolean canModify(Long currentUserId, Long authorUserId, HttpSession session) {
|
||||||
return ROLE_ADMIN.equals(role);
|
return (authorUserId != null && authorUserId.equals(currentUserId)) || permissionGate.canModerate(session);
|
||||||
}
|
|
||||||
|
|
||||||
private boolean canModify(Long currentUserId, Long authorUserId, String role) {
|
|
||||||
return (authorUserId != null && authorUserId.equals(currentUserId)) || isOperator(role);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long sessionUserId(HttpSession session) {
|
private Long sessionUserId(HttpSession session) {
|
||||||
|
|
@ -231,14 +229,6 @@ public class GameCommentController {
|
||||||
return displayName instanceof String text ? text : null;
|
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) {
|
private String trimToNull(String value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||||
|
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||||
import com.pandoli365.bibimbap.util.TextNormalizer;
|
import com.pandoli365.bibimbap.util.TextNormalizer;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpSession;
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
|
@ -34,22 +35,24 @@ public class GameReviewController {
|
||||||
private static final int BODY_MIN = 10;
|
private static final int BODY_MIN = 10;
|
||||||
private static final int BODY_MAX = 1000;
|
private static final int BODY_MAX = 1000;
|
||||||
private static final int PAGE_SIZE = 20;
|
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 static final String[] AXIS_KEYS = {"immersion", "creativity", "controls", "completeness", "sound", "visual"};
|
||||||
|
|
||||||
private final GameReviewsMapper gameReviewsMapper;
|
private final GameReviewsMapper gameReviewsMapper;
|
||||||
private final GameReviewAxesMapper gameReviewAxesMapper;
|
private final GameReviewAxesMapper gameReviewAxesMapper;
|
||||||
private final GameReviewStatsMapper gameReviewStatsMapper;
|
private final GameReviewStatsMapper gameReviewStatsMapper;
|
||||||
private final GamesMapper gamesMapper;
|
private final GamesMapper gamesMapper;
|
||||||
|
private final PermissionGate permissionGate;
|
||||||
|
|
||||||
public GameReviewController(GameReviewsMapper gameReviewsMapper,
|
public GameReviewController(GameReviewsMapper gameReviewsMapper,
|
||||||
GameReviewAxesMapper gameReviewAxesMapper,
|
GameReviewAxesMapper gameReviewAxesMapper,
|
||||||
GameReviewStatsMapper gameReviewStatsMapper,
|
GameReviewStatsMapper gameReviewStatsMapper,
|
||||||
GamesMapper gamesMapper) {
|
GamesMapper gamesMapper,
|
||||||
|
PermissionGate permissionGate) {
|
||||||
this.gameReviewsMapper = gameReviewsMapper;
|
this.gameReviewsMapper = gameReviewsMapper;
|
||||||
this.gameReviewAxesMapper = gameReviewAxesMapper;
|
this.gameReviewAxesMapper = gameReviewAxesMapper;
|
||||||
this.gameReviewStatsMapper = gameReviewStatsMapper;
|
this.gameReviewStatsMapper = gameReviewStatsMapper;
|
||||||
this.gamesMapper = gamesMapper;
|
this.gamesMapper = gamesMapper;
|
||||||
|
this.permissionGate = permissionGate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/game/{id}/reviews")
|
@GetMapping("/game/{id}/reviews")
|
||||||
|
|
@ -220,7 +223,7 @@ public class GameReviewController {
|
||||||
if (review == null || !Long.valueOf(id).equals(review.getGameId())) {
|
if (review == null || !Long.valueOf(id).equals(review.getGameId())) {
|
||||||
return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다.");
|
return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
if (!canModify(userId, review.getUserId(), sessionRole(session))) {
|
if (!canModify(userId, review.getUserId(), session)) {
|
||||||
return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다.");
|
return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -286,7 +289,7 @@ public class GameReviewController {
|
||||||
if (review == null || !Long.valueOf(id).equals(review.getGameId())) {
|
if (review == null || !Long.valueOf(id).equals(review.getGameId())) {
|
||||||
return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다.");
|
return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
if (!canModify(userId, review.getUserId(), sessionRole(session))) {
|
if (!canModify(userId, review.getUserId(), session)) {
|
||||||
return response(HttpStatus.FORBIDDEN, "작성자만 삭제할 수 있습니다.");
|
return response(HttpStatus.FORBIDDEN, "작성자만 삭제할 수 있습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -415,12 +418,8 @@ public class GameReviewController {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isOperator(String role) {
|
private boolean canModify(Long currentUserId, Long authorUserId, HttpSession session) {
|
||||||
return ROLE_ADMIN.equals(role);
|
return (authorUserId != null && authorUserId.equals(currentUserId)) || permissionGate.canModerate(session);
|
||||||
}
|
|
||||||
|
|
||||||
private boolean canModify(Long currentUserId, Long authorUserId, String role) {
|
|
||||||
return (authorUserId != null && authorUserId.equals(currentUserId)) || isOperator(role);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long sessionUserId(HttpSession session) {
|
private Long sessionUserId(HttpSession session) {
|
||||||
|
|
@ -441,14 +440,6 @@ public class GameReviewController {
|
||||||
return null;
|
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) {
|
private String trimToNull(String value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.pandoli365.bibimbap.controller.api;
|
||||||
import com.pandoli365.bibimbap.data.UserAuthIdentityData;
|
import com.pandoli365.bibimbap.data.UserAuthIdentityData;
|
||||||
import com.pandoli365.bibimbap.data.UserData;
|
import com.pandoli365.bibimbap.data.UserData;
|
||||||
import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
|
import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
||||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
@ -31,9 +32,11 @@ import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
|
|
@ -52,14 +55,17 @@ public class UserController {
|
||||||
|
|
||||||
private final UsersMapper usersMapper;
|
private final UsersMapper usersMapper;
|
||||||
private final UserAuthIdentitiesMapper userAuthIdentitiesMapper;
|
private final UserAuthIdentitiesMapper userAuthIdentitiesMapper;
|
||||||
|
private final UserPermissionsMapper userPermissionsMapper;
|
||||||
private final SecureRandom secureRandom = new SecureRandom();
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
@Value("${app.upload.game-storage-path:src/main/resources/static}")
|
@Value("${app.upload.game-storage-path:src/main/resources/static}")
|
||||||
private String uploadStoragePath;
|
private String uploadStoragePath;
|
||||||
|
|
||||||
public UserController(UsersMapper usersMapper, UserAuthIdentitiesMapper userAuthIdentitiesMapper) {
|
public UserController(UsersMapper usersMapper, UserAuthIdentitiesMapper userAuthIdentitiesMapper,
|
||||||
|
UserPermissionsMapper userPermissionsMapper) {
|
||||||
this.usersMapper = usersMapper;
|
this.usersMapper = usersMapper;
|
||||||
this.userAuthIdentitiesMapper = userAuthIdentitiesMapper;
|
this.userAuthIdentitiesMapper = userAuthIdentitiesMapper;
|
||||||
|
this.userPermissionsMapper = userPermissionsMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/signup")
|
@PostMapping("/signup")
|
||||||
|
|
@ -522,6 +528,12 @@ public class UserController {
|
||||||
account.put("authIdentityId", identity.getId());
|
account.put("authIdentityId", identity.getId());
|
||||||
account.put("lastLoginAt", user.getLastLoginAt());
|
account.put("lastLoginAt", user.getLastLoginAt());
|
||||||
session.setAttribute("account", account);
|
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) {
|
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 canonicalEmail;
|
||||||
private String avatarUrl;
|
private String avatarUrl;
|
||||||
private String role;
|
private String role;
|
||||||
|
private Long permissionsEpoch;
|
||||||
private String status;
|
private String status;
|
||||||
private OffsetDateTime lastLoginAt;
|
private OffsetDateTime lastLoginAt;
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
|
|
@ -85,4 +86,12 @@ public class UserData {
|
||||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||||
this.updatedAt = 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;
|
package com.pandoli365.bibimbap.mapper;
|
||||||
|
|
||||||
|
import com.pandoli365.bibimbap.data.OperatorView;
|
||||||
import com.pandoli365.bibimbap.data.UserData;
|
import com.pandoli365.bibimbap.data.UserData;
|
||||||
import org.apache.ibatis.annotations.Insert;
|
import org.apache.ibatis.annotations.Insert;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Options;
|
import org.apache.ibatis.annotations.Options;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
import org.apache.ibatis.annotations.Update;
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface UsersMapper {
|
public interface UsersMapper {
|
||||||
|
|
||||||
|
|
@ -61,4 +65,41 @@ public interface UsersMapper {
|
||||||
AND is_delete IS NOT TRUE
|
AND is_delete IS NOT TRUE
|
||||||
""")
|
""")
|
||||||
int updateUser(UserData user);
|
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.GameReviewStatsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
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.RecruitPostsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
|
import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
||||||
|
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
|
@ -44,6 +48,18 @@ class BibimbapApplicationTests {
|
||||||
@MockBean
|
@MockBean
|
||||||
private UsersMapper usersMapper;
|
private UsersMapper usersMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private PermissionsMapper permissionsMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private UserPermissionsMapper userPermissionsMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private RbacAuditMapper rbacAuditMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private PermissionGate permissionGate;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void contextLoads() {
|
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.GameCommentsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||||
|
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
@ -39,6 +40,9 @@ class GameCommentControllerTest {
|
||||||
@Mock
|
@Mock
|
||||||
private GamesMapper gamesMapper;
|
private GamesMapper gamesMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PermissionGate permissionGate;
|
||||||
|
|
||||||
// ---- AC-1: 댓글 CRUD ----
|
// ---- AC-1: 댓글 CRUD ----
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -134,6 +138,22 @@ class GameCommentControllerTest {
|
||||||
MockHttpSession session = loginSession(99L, "ADMIN", "운영자");
|
MockHttpSession session = loginSession(99L, "ADMIN", "운영자");
|
||||||
MockHttpServletRequest request = csrfPost(session);
|
MockHttpServletRequest request = csrfPost(session);
|
||||||
when(gameCommentsMapper.getGameComment(5L)).thenReturn(comment(5L, 1L, 7L));
|
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 =
|
ResponseEntity<Map<String, Object>> response =
|
||||||
controller.deleteComment(1L, 5L, request, session);
|
controller.deleteComment(1L, 5L, request, session);
|
||||||
|
|
@ -370,7 +390,7 @@ class GameCommentControllerTest {
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
|
|
||||||
private GameCommentController controller() {
|
private GameCommentController controller() {
|
||||||
return new GameCommentController(gameCommentsMapper, gamesMapper);
|
return new GameCommentController(gameCommentsMapper, gamesMapper, permissionGate);
|
||||||
}
|
}
|
||||||
|
|
||||||
private GameData game(long id) {
|
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.GameReviewsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||||
|
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
@ -50,6 +51,9 @@ class GameReviewControllerTest {
|
||||||
@Mock
|
@Mock
|
||||||
private GamesMapper gamesMapper;
|
private GamesMapper gamesMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PermissionGate permissionGate;
|
||||||
|
|
||||||
// 6축 유효 입력(immersion,creativity,controls,completeness,sound,visual).
|
// 6축 유효 입력(immersion,creativity,controls,completeness,sound,visual).
|
||||||
private static final String[] AXES_OK = {"4", "5", "3", "4", "2", "5"};
|
private static final String[] AXES_OK = {"4", "5", "3", "4", "2", "5"};
|
||||||
|
|
||||||
|
|
@ -170,6 +174,24 @@ class GameReviewControllerTest {
|
||||||
MockHttpSession session = loginSession(99L, "ADMIN");
|
MockHttpSession session = loginSession(99L, "ADMIN");
|
||||||
MockHttpServletRequest request = csrfPost(session);
|
MockHttpServletRequest request = csrfPost(session);
|
||||||
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
|
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 =
|
ResponseEntity<Map<String, Object>> response =
|
||||||
controller.deleteReview(1L, 50L, request, session);
|
controller.deleteReview(1L, 50L, request, session);
|
||||||
|
|
@ -422,7 +444,7 @@ class GameReviewControllerTest {
|
||||||
|
|
||||||
private GameReviewController controller() {
|
private GameReviewController controller() {
|
||||||
return new GameReviewController(
|
return new GameReviewController(
|
||||||
gameReviewsMapper, gameReviewAxesMapper, gameReviewStatsMapper, gamesMapper);
|
gameReviewsMapper, gameReviewAxesMapper, gameReviewStatsMapper, gamesMapper, permissionGate);
|
||||||
}
|
}
|
||||||
|
|
||||||
private GameData game(long id) {
|
private GameData game(long id) {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ class UserControllerCsrfTest {
|
||||||
@Mock
|
@Mock
|
||||||
private UserAuthIdentitiesMapper userAuthIdentitiesMapper;
|
private UserAuthIdentitiesMapper userAuthIdentitiesMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private com.pandoli365.bibimbap.mapper.UserPermissionsMapper userPermissionsMapper;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void signupRejectsMissingCsrfBeforeMapperAccess() {
|
void signupRejectsMissingCsrfBeforeMapperAccess() {
|
||||||
UserController controller = controller();
|
UserController controller = controller();
|
||||||
|
|
@ -154,7 +157,7 @@ class UserControllerCsrfTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserController controller() {
|
private UserController controller() {
|
||||||
return new UserController(usersMapper, userAuthIdentitiesMapper);
|
return new UserController(usersMapper, userAuthIdentitiesMapper, userPermissionsMapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
private MockHttpServletRequest jsonPost(String path) {
|
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