feat(settings): 관리자 설정 기반 신규 사용자 자동승인 + 기본 롤 부여

- AppSetting 엔티티 + Repository (key-value 설정 저장소)
- SettingsService (자동승인 조회/수정)
- AdminSettingsController (GET/PUT /api/admin/settings/registration)
- Role.defaultGrant 컬럼 + AdminRoleController default-grant 토글
- AuthController: 신규 사용자 생성 시 자동승인 + 기본롤 부여 로직
- data.sql: WING_PERMIT 롤 시드 + auto-approve 설정 시드

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-16 23:19:50 +09:00
부모 04f3de3890
커밋 ce6e88e221
14개의 변경된 파일247개의 추가작업 그리고 7개의 파일을 삭제

파일 보기

@ -3,9 +3,12 @@ package com.gcsc.guide.auth;
import com.gcsc.guide.dto.AuthResponse; import com.gcsc.guide.dto.AuthResponse;
import com.gcsc.guide.dto.GoogleLoginRequest; import com.gcsc.guide.dto.GoogleLoginRequest;
import com.gcsc.guide.dto.UserResponse; import com.gcsc.guide.dto.UserResponse;
import com.gcsc.guide.entity.Role;
import com.gcsc.guide.entity.User; import com.gcsc.guide.entity.User;
import com.gcsc.guide.repository.RoleRepository;
import com.gcsc.guide.repository.UserRepository; import com.gcsc.guide.repository.UserRepository;
import com.gcsc.guide.service.ActivityService; import com.gcsc.guide.service.ActivityService;
import com.gcsc.guide.service.SettingsService;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
@ -18,6 +21,10 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
@ -36,7 +43,9 @@ public class AuthController {
private final GoogleTokenVerifier googleTokenVerifier; private final GoogleTokenVerifier googleTokenVerifier;
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository; private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final ActivityService activityService; private final ActivityService activityService;
private final SettingsService settingsService;
@Operation(summary = "Google 로그인", @Operation(summary = "Google 로그인",
description = "Google ID Token을 검증하고 JWT를 발급합니다. " description = "Google ID Token을 검증하고 JWT를 발급합니다. "
@ -119,6 +128,16 @@ public class AuthController {
newUser.activate(); newUser.activate();
newUser.grantAdmin(); newUser.grantAdmin();
log.info("관리자 자동 승인: {}", email); log.info("관리자 자동 승인: {}", email);
} else if (settingsService.isAutoApproveEnabled()) {
newUser.activate();
log.info("자동 승인 (설정 활성화): {}", email);
}
List<Role> defaultRoles = roleRepository.findByDefaultGrantTrue();
if (!defaultRoles.isEmpty()) {
newUser.updateRoles(new HashSet<>(defaultRoles));
log.info("기본 롤 부여: {} → {}", email,
defaultRoles.stream().map(Role::getName).toList());
} }
newUser.updateLastLogin(); newUser.updateLastLogin();

파일 보기

@ -3,6 +3,7 @@ package com.gcsc.guide.controller;
import com.gcsc.guide.dto.AddPermissionRequest; import com.gcsc.guide.dto.AddPermissionRequest;
import com.gcsc.guide.dto.CreateRoleRequest; import com.gcsc.guide.dto.CreateRoleRequest;
import com.gcsc.guide.dto.RoleResponse; import com.gcsc.guide.dto.RoleResponse;
import com.gcsc.guide.dto.UpdateDefaultGrantRequest;
import com.gcsc.guide.service.RoleService; import com.gcsc.guide.service.RoleService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
@ -119,6 +120,22 @@ public class AdminRoleController {
return ResponseEntity.ok(roleService.addPermission(id, request.urlPattern())); return ResponseEntity.ok(roleService.addPermission(id, request.urlPattern()));
} }
@Operation(summary = "롤 기본 부여 토글",
description = "신규 가입자에게 해당 롤을 기본 부여할지 설정합니다. defaultGrant=true인 롤은 첫 로그인 시 자동 할당됩니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "수정 성공",
content = @Content(schema = @Schema(implementation = RoleResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content),
@ApiResponse(responseCode = "404", description = "롤을 찾을 수 없음", content = @Content)
})
@PutMapping("/{id}/default-grant")
public ResponseEntity<RoleResponse> updateDefaultGrant(
@Parameter(description = "롤 ID", required = true) @PathVariable Long id,
@Valid @RequestBody UpdateDefaultGrantRequest request) {
return ResponseEntity.ok(roleService.updateDefaultGrant(id, request.defaultGrant()));
}
@Operation(summary = "URL 패턴 삭제", @Operation(summary = "URL 패턴 삭제",
description = "특정 URL 패턴(권한)을 삭제합니다.") description = "특정 URL 패턴(권한)을 삭제합니다.")
@ApiResponses({ @ApiResponses({

파일 보기

@ -0,0 +1,53 @@
package com.gcsc.guide.controller;
import com.gcsc.guide.dto.RegistrationSettingsResponse;
import com.gcsc.guide.dto.UpdateRegistrationSettingsRequest;
import com.gcsc.guide.service.SettingsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/admin/settings")
@RequiredArgsConstructor
@Tag(name = "07. 관리자 - 설정", description = "신규 가입 자동승인, 기본 롤 부여 등 시스템 설정 관리")
@SecurityRequirement(name = "Bearer JWT")
public class AdminSettingsController {
private final SettingsService settingsService;
@Operation(summary = "가입 설정 조회",
description = "자동 승인 여부와 기본 부여 롤 목록을 조회합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(schema = @Schema(implementation = RegistrationSettingsResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content)
})
@GetMapping("/registration")
public ResponseEntity<RegistrationSettingsResponse> getRegistrationSettings() {
return ResponseEntity.ok(settingsService.getRegistrationSettings());
}
@Operation(summary = "가입 설정 수정",
description = "자동 승인 여부를 변경합니다. true로 설정하면 신규 가입자가 즉시 ACTIVE 상태가 됩니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "수정 성공",
content = @Content(schema = @Schema(implementation = RegistrationSettingsResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content)
})
@PutMapping("/registration")
public ResponseEntity<RegistrationSettingsResponse> updateRegistrationSettings(
@Valid @RequestBody UpdateRegistrationSettingsRequest request) {
return ResponseEntity.ok(settingsService.updateAutoApprove(request.autoApprove()));
}
}

파일 보기

@ -0,0 +1,8 @@
package com.gcsc.guide.dto;
import java.util.List;
public record RegistrationSettingsResponse(
boolean autoApprove,
List<RoleResponse> defaultRoles
) {}

파일 보기

@ -9,7 +9,8 @@ public record RoleResponse(
Long id, Long id,
String name, String name,
String description, String description,
List<String> urlPatterns List<String> urlPatterns,
boolean defaultGrant
) { ) {
public static RoleResponse from(Role role) { public static RoleResponse from(Role role) {
@ -21,7 +22,8 @@ public record RoleResponse(
role.getId(), role.getId(),
role.getName(), role.getName(),
role.getDescription(), role.getDescription(),
patterns patterns,
role.isDefaultGrant()
); );
} }
} }

파일 보기

@ -0,0 +1,7 @@
package com.gcsc.guide.dto;
import jakarta.validation.constraints.NotNull;
public record UpdateDefaultGrantRequest(
@NotNull Boolean defaultGrant
) {}

파일 보기

@ -0,0 +1,7 @@
package com.gcsc.guide.dto;
import jakarta.validation.constraints.NotNull;
public record UpdateRegistrationSettingsRequest(
@NotNull Boolean autoApprove
) {}

파일 보기

@ -0,0 +1,46 @@
package com.gcsc.guide.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "app_settings")
@Getter
@NoArgsConstructor
public class AppSetting {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "setting_key", nullable = false, unique = true, length = 100)
private String settingKey;
@Column(name = "setting_value", nullable = false, length = 500)
private String settingValue;
@Column(length = 255)
private String description;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public AppSetting(String settingKey, String settingValue, String description) {
this.settingKey = settingKey;
this.settingValue = settingValue;
this.description = description;
}
public void updateValue(String value) {
this.settingValue = value;
this.updatedAt = LocalDateTime.now();
}
@PrePersist
protected void onCreate() {
this.updatedAt = LocalDateTime.now();
}
}

파일 보기

@ -24,6 +24,9 @@ public class Role {
@Column(length = 255) @Column(length = 255)
private String description; private String description;
@Column(name = "default_grant")
private boolean defaultGrant = false;
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@ -40,6 +43,10 @@ public class Role {
this.description = description; this.description = description;
} }
public void updateDefaultGrant(boolean defaultGrant) {
this.defaultGrant = defaultGrant;
}
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
this.createdAt = LocalDateTime.now(); this.createdAt = LocalDateTime.now();

파일 보기

@ -0,0 +1,11 @@
package com.gcsc.guide.repository;
import com.gcsc.guide.entity.AppSetting;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface AppSettingRepository extends JpaRepository<AppSetting, Long> {
Optional<AppSetting> findBySettingKey(String settingKey);
}

파일 보기

@ -16,4 +16,6 @@ public interface RoleRepository extends JpaRepository<Role, Long> {
@Query("SELECT r FROM Role r LEFT JOIN FETCH r.urlPatterns WHERE r.id = :id") @Query("SELECT r FROM Role r LEFT JOIN FETCH r.urlPatterns WHERE r.id = :id")
Optional<Role> findByIdWithUrlPatterns(Long id); Optional<Role> findByIdWithUrlPatterns(Long id);
List<Role> findByDefaultGrantTrue();
} }

파일 보기

@ -81,6 +81,14 @@ public class RoleService {
roleUrlPatternRepository.deleteById(permissionId); roleUrlPatternRepository.deleteById(permissionId);
} }
@Transactional
public RoleResponse updateDefaultGrant(Long roleId, boolean defaultGrant) {
Role role = roleRepository.findByIdWithUrlPatterns(roleId)
.orElseThrow(() -> new ResourceNotFoundException("", roleId));
role.updateDefaultGrant(defaultGrant);
return RoleResponse.from(roleRepository.save(role));
}
private Role findRoleById(Long roleId) { private Role findRoleById(Long roleId) {
return roleRepository.findById(roleId) return roleRepository.findById(roleId)
.orElseThrow(() -> new ResourceNotFoundException("", roleId)); .orElseThrow(() -> new ResourceNotFoundException("", roleId));

파일 보기

@ -0,0 +1,47 @@
package com.gcsc.guide.service;
import com.gcsc.guide.dto.RegistrationSettingsResponse;
import com.gcsc.guide.dto.RoleResponse;
import com.gcsc.guide.entity.AppSetting;
import com.gcsc.guide.repository.AppSettingRepository;
import com.gcsc.guide.repository.RoleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class SettingsService {
private static final String AUTO_APPROVE_KEY = "registration.auto-approve";
private final AppSettingRepository appSettingRepository;
private final RoleRepository roleRepository;
@Transactional(readOnly = true)
public boolean isAutoApproveEnabled() {
return appSettingRepository.findBySettingKey(AUTO_APPROVE_KEY)
.map(s -> Boolean.parseBoolean(s.getSettingValue()))
.orElse(false);
}
@Transactional(readOnly = true)
public RegistrationSettingsResponse getRegistrationSettings() {
boolean autoApprove = isAutoApproveEnabled();
List<RoleResponse> defaultRoles = roleRepository.findByDefaultGrantTrue().stream()
.map(RoleResponse::from)
.toList();
return new RegistrationSettingsResponse(autoApprove, defaultRoles);
}
@Transactional
public RegistrationSettingsResponse updateAutoApprove(boolean autoApprove) {
AppSetting setting = appSettingRepository.findBySettingKey(AUTO_APPROVE_KEY)
.orElseGet(() -> new AppSetting(AUTO_APPROVE_KEY, "false", "신규 가입자 자동 승인 여부"));
setting.updateValue(String.valueOf(autoApprove));
appSettingRepository.save(setting);
return getRegistrationSettings();
}
}

파일 보기

@ -1,11 +1,17 @@
-- 초기 롤 시드 데이터 -- 초기 롤 시드 데이터
INSERT INTO roles (name, description, created_at) VALUES INSERT INTO roles (name, description, default_grant, created_at) VALUES
('ADMIN', '전체 접근 권한 (관리자 페이지 포함)', NOW()), ('ADMIN', '전체 접근 권한 (관리자 페이지 포함)', false, NOW()),
('DEVELOPER', '전체 개발 가이드 접근', NOW()), ('DEVELOPER', '전체 개발 가이드 접근', false, NOW()),
('FRONT_DEV', '프론트엔드 개발 가이드만', NOW()); ('FRONT_DEV', '프론트엔드 개발 가이드만', false, NOW()),
('WING_PERMIT', 'Wing 데모 사이트 접근 권한', true, NOW());
-- 롤별 URL 패턴 -- 롤별 URL 패턴
INSERT INTO role_url_patterns (role_id, url_pattern, created_at) VALUES INSERT INTO role_url_patterns (role_id, url_pattern, created_at) VALUES
((SELECT id FROM roles WHERE name = 'ADMIN'), '/**', NOW()), ((SELECT id FROM roles WHERE name = 'ADMIN'), '/**', NOW()),
((SELECT id FROM roles WHERE name = 'DEVELOPER'), '/dev/**', NOW()), ((SELECT id FROM roles WHERE name = 'DEVELOPER'), '/dev/**', NOW()),
((SELECT id FROM roles WHERE name = 'FRONT_DEV'), '/dev/front/**', NOW()); ((SELECT id FROM roles WHERE name = 'FRONT_DEV'), '/dev/front/**', NOW()),
((SELECT id FROM roles WHERE name = 'WING_PERMIT'), '/wing/**', NOW());
-- 시스템 설정
INSERT INTO app_settings (setting_key, setting_value, description, updated_at) VALUES
('registration.auto-approve', 'true', '신규 가입자 자동 승인 여부', NOW());