diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx index bd8ddcc..6d16f68 100644 --- a/admin-web/src/App.tsx +++ b/admin-web/src/App.tsx @@ -8,6 +8,7 @@ import { AdminLayout } from '@/components/layout/AdminLayout' import { LoginPage } from '@/pages/login' import { MemberList } from '@/pages/members/list' import { MemberShow } from '@/pages/members/show' +import { AdminAccountList } from '@/pages/admin-accounts/list' import { LlmSettingsPage } from '@/pages/llm-settings' // mock(로컬) 모드에서는 Firebase 없이 dev-login 을 쓴다. @@ -20,7 +21,10 @@ export default function App() { dataProvider={dataProvider} authProvider={authProvider} routerProvider={routerBindings} - resources={[{ name: 'members', list: '/members', show: '/members/:id', meta: { label: '회원 관리' } }]} + resources={[ + { name: 'members', list: '/members', show: '/members/:id', meta: { label: '회원 관리' } }, + { name: 'admin-accounts', list: '/admin-accounts', meta: { label: '관리자 관리' } }, + ]} options={{ syncWithLocation: true, disableTelemetry: true }} > @@ -34,6 +38,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/admin-web/src/components/layout/AdminLayout.tsx b/admin-web/src/components/layout/AdminLayout.tsx index 9c44b3f..6b62779 100644 --- a/admin-web/src/components/layout/AdminLayout.tsx +++ b/admin-web/src/components/layout/AdminLayout.tsx @@ -1,6 +1,6 @@ import { useGetIdentity, useLogout } from '@refinedev/core' import { Link, Outlet, useLocation } from 'react-router-dom' -import { LayoutDashboard, LogOut, Sparkles, Users } from 'lucide-react' +import { LayoutDashboard, LogOut, ShieldCheck, Sparkles, Users } from 'lucide-react' import { Avatar } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -17,6 +17,7 @@ interface NavItem { const NAV: NavItem[] = [ { label: '대시보드', to: '/dashboard', icon: LayoutDashboard, disabled: true }, { label: '회원 관리', to: '/members', icon: Users }, + { label: '관리자 관리', to: '/admin-accounts', icon: ShieldCheck }, { label: 'LLM 설정', to: '/llm-settings', icon: Sparkles }, ] @@ -30,6 +31,9 @@ function sectionOf(pathname: string): string { if (pathname.startsWith('/members')) { return '회원 관리' } + if (pathname.startsWith('/admin-accounts')) { + return '관리자 관리' + } if (pathname.startsWith('/dashboard')) { return '대시보드' } diff --git a/admin-web/src/pages/admin-accounts/list.tsx b/admin-web/src/pages/admin-accounts/list.tsx new file mode 100644 index 0000000..e94d53e --- /dev/null +++ b/admin-web/src/pages/admin-accounts/list.tsx @@ -0,0 +1,216 @@ +import { useState } from 'react' +import { useCustom, useCustomMutation } from '@refinedev/core' +import { Plus, RotateCcw, ShieldCheck, Trash2 } from 'lucide-react' +import type { AdminAccount } from '@/types/adminAccount' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { PageHeader } from '@/components/page-header' +import { formatDateTime } from '@/lib/format' + +function mapError(code?: string): string { + switch (code) { + case 'ADMIN_ACCOUNT_ALREADY_EXISTS': + return '이미 등록된 관리자입니다.' + case 'INVALID_INPUT': + return '이메일 형식이 올바르지 않습니다.' + case 'CANNOT_REMOVE_SELF': + return '자기 자신은 삭제할 수 없습니다.' + case 'ADMIN_ACCOUNT_NOT_FOUND': + return '이미 삭제된 관리자입니다.' + default: + return '요청에 실패했습니다.' + } +} + +export function AdminAccountList() { + const { data, isLoading, isError, refetch } = useCustom({ + url: '/api/admin/accounts', + method: 'get', + }) + const { mutate: addMutate, isLoading: adding } = useCustomMutation() + const { mutate: removeMutate } = useCustomMutation() + + const [email, setEmail] = useState('') + const [error, setError] = useState(null) + + const accounts = data?.data ?? [] + + const submit = () => { + const value = email.trim() + if (!value || adding) { + return + } + setError(null) + addMutate( + { url: '/api/admin/accounts', method: 'post', values: { email: value } }, + { + onSuccess: () => { + setEmail('') + refetch() + }, + onError: (e) => setError(mapError(e?.message)), + }, + ) + } + + const remove = (id: number) => { + setError(null) + removeMutate( + { url: `/api/admin/accounts/${id}`, method: 'delete', values: {} }, + { + onSuccess: () => refetch(), + onError: (e) => setError(mapError(e?.message)), + }, + ) + } + + return ( +
+ + + + +
+ setEmail(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + submit() + } + }} + /> + +
+ {error &&

{error}

} +

+ 추가한 계정은 다음 구글 로그인부터 백오피스에 접근할 수 있습니다. 구글 로그인만 허용됩니다. +

+
+ + {isError && !data ? ( + +

관리자 목록을 불러오지 못했습니다.

+ +
+ ) : ( + + + + + 이메일 + 출처 + 추가한 관리자 + 추가일 + + + + + {isLoading ? ( + Array.from({ length: 3 }).map((_, i) => ( + + + + + + + + + + + + + + + + )) + ) : ( + accounts.map((account) => ( + + {account.email} + + {account.source === 'ENV' ? ( + 환경 설정 + ) : ( + 관리 + )} + + + {account.addedByEmail ?? } + + + {account.createdAt ? formatDateTime(account.createdAt) : } + + + {account.removable && account.id !== null && ( + + + + + + + 관리자를 삭제할까요? + + {account.email} 계정의 백오피스 + 접근이 즉시 차단됩니다. + + + + 취소 + remove(account.id as number)}>삭제 + + + + )} + + + )) + )} + +
+
+ + 환경 설정(부트스트랩) 관리자는 안전장치로 UI에서 삭제할 수 없습니다. +
+
+ )} +
+ ) +} diff --git a/admin-web/src/types/adminAccount.ts b/admin-web/src/types/adminAccount.ts new file mode 100644 index 0000000..480b56f --- /dev/null +++ b/admin-web/src/types/adminAccount.ts @@ -0,0 +1,8 @@ +export interface AdminAccount { + id: number | null + email: string + source: 'ENV' | 'DB' + removable: boolean + addedByEmail: string | null + createdAt: string | null +} diff --git a/src/main/kotlin/com/nexters/gamss/admin/config/AdminProperties.kt b/src/main/kotlin/com/nexters/gamss/admin/config/AdminProperties.kt index 0d62a24..e23463b 100644 --- a/src/main/kotlin/com/nexters/gamss/admin/config/AdminProperties.kt +++ b/src/main/kotlin/com/nexters/gamss/admin/config/AdminProperties.kt @@ -16,6 +16,9 @@ data class AdminProperties( fun isAllowed(email: String): Boolean = normalize(email) in allowed + /** 환경(ENV)으로 고정된 부트스트랩 허용 이메일. UI로는 지울 수 없는 break-glass 목록이다. */ + fun bootstrapEmails(): Set = allowed + /** 이메일 비교·저장에 쓰는 정규화(공백 제거·소문자). 토큰 발급 등 다른 곳에서도 재사용한다. */ fun normalize(email: String): String = email.trim().lowercase() } diff --git a/src/main/kotlin/com/nexters/gamss/admin/controller/AdminAccountController.kt b/src/main/kotlin/com/nexters/gamss/admin/controller/AdminAccountController.kt new file mode 100644 index 0000000..0094ba6 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/admin/controller/AdminAccountController.kt @@ -0,0 +1,79 @@ +package com.nexters.gamss.admin.controller + +import com.nexters.gamss.admin.controller.dto.AddAdminAccountRequest +import com.nexters.gamss.admin.controller.dto.AdminAccountResponse +import com.nexters.gamss.admin.service.AdminAccountEntry +import com.nexters.gamss.admin.service.AdminAccountService +import com.nexters.gamss.global.response.ApiResponse +import com.nexters.gamss.global.security.AdminPrincipal +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag( + name = "백오피스 관리자", + description = "백오피스 접근 허용 관리자 조회·추가·삭제 API (ROLE_ADMIN 필요). 재배포 없이 즉시 반영.", +) +@RestController +@RequestMapping("/api/admin/accounts") +class AdminAccountController( + private val adminAccountService: AdminAccountService, +) { + @Operation( + summary = "관리자 목록 조회", + description = "허용 관리자를 반환합니다. ENV 부트스트랩(삭제 불가)과 DB 관리 항목을 함께 보여줍니다.", + ) + @GetMapping + fun list( + @Parameter(hidden = true) @AuthenticationPrincipal principal: AdminPrincipal, + ): ApiResponse> = + ApiResponse.success(adminAccountService.list(principal.email).map { AdminAccountResponse.from(it) }) + + @Operation( + summary = "관리자 추가", + description = + "이메일로 관리자를 추가합니다. 다음 로그인부터 즉시 허용됩니다.\n\n" + + "**실패 응답**\n\n" + + "| error.code | HTTP | 설명 |\n" + + "|---|---|---|\n" + + "| INVALID_INPUT | 400 | email 누락 또는 형식 오류 |\n" + + "| ADMIN_ACCOUNT_ALREADY_EXISTS | 409 | 이미 허용된(ENV·DB) 관리자 |", + ) + @PostMapping + fun add( + @Parameter(hidden = true) @AuthenticationPrincipal principal: AdminPrincipal, + @Valid @RequestBody request: AddAdminAccountRequest, + ): ApiResponse { + val account = adminAccountService.add(checkNotNull(request.email), principal.email) + // 방금 추가한 계정은 남을 추가한 것이라(본인은 이미 허용돼 추가 불가) 삭제 가능하다. + return ApiResponse.success(AdminAccountResponse.from(AdminAccountEntry.db(account, removable = true))) + } + + @Operation( + summary = "관리자 삭제", + description = + "DB로 관리되는 관리자를 삭제합니다. 자기 자신은 삭제할 수 없습니다.\n\n" + + "**실패 응답**\n\n" + + "| error.code | HTTP | 설명 |\n" + + "|---|---|---|\n" + + "| ADMIN_ACCOUNT_NOT_FOUND | 404 | 존재하지 않는 관리자 |\n" + + "| CANNOT_REMOVE_SELF | 409 | 자기 자신은 삭제 불가 |", + ) + @DeleteMapping("/{id}") + fun remove( + @Parameter(hidden = true) @AuthenticationPrincipal principal: AdminPrincipal, + @PathVariable id: Long, + ): ApiResponse { + adminAccountService.remove(id, principal.email) + return ApiResponse.success() + } +} diff --git a/src/main/kotlin/com/nexters/gamss/admin/controller/dto/AddAdminAccountRequest.kt b/src/main/kotlin/com/nexters/gamss/admin/controller/dto/AddAdminAccountRequest.kt new file mode 100644 index 0000000..b5b7dce --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/admin/controller/dto/AddAdminAccountRequest.kt @@ -0,0 +1,14 @@ +package com.nexters.gamss.admin.controller.dto + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + +data class AddAdminAccountRequest( + @field:NotBlank(message = "email은 필수입니다.") + @field:Email(message = "이메일 형식이 올바르지 않습니다.") + @field:Size(max = 255, message = "email은 255자 이하여야 합니다.") + @field:Schema(description = "관리자로 추가할 이메일", example = "teammate@gmail.com") + val email: String?, +) diff --git a/src/main/kotlin/com/nexters/gamss/admin/controller/dto/AdminAccountResponse.kt b/src/main/kotlin/com/nexters/gamss/admin/controller/dto/AdminAccountResponse.kt new file mode 100644 index 0000000..a192b76 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/admin/controller/dto/AdminAccountResponse.kt @@ -0,0 +1,32 @@ +package com.nexters.gamss.admin.controller.dto + +import com.nexters.gamss.admin.service.AdminAccountEntry +import io.swagger.v3.oas.annotations.media.Schema +import java.time.Instant + +data class AdminAccountResponse( + @field:Schema(description = "DB 관리 항목의 ID(ENV 부트스트랩은 null)", example = "1") + val id: Long?, + @field:Schema(description = "관리자 이메일", example = "teammate@gmail.com") + val email: String, + @field:Schema(description = "출처", example = "DB", allowableValues = ["ENV", "DB"]) + val source: String, + @field:Schema(description = "UI에서 삭제 가능한지(ENV 부트스트랩·본인 계정은 false)", example = "true") + val removable: Boolean, + @field:Schema(description = "이 관리자를 추가한 관리자 이메일(ENV·초기값은 null)", example = "admin@gamss.kr") + val addedByEmail: String?, + @field:Schema(description = "추가 일시(ENV는 null)") + val createdAt: Instant?, +) { + companion object { + fun from(entry: AdminAccountEntry): AdminAccountResponse = + AdminAccountResponse( + id = entry.id, + email = entry.email, + source = entry.source.name, + removable = entry.removable, + addedByEmail = entry.createdByEmail, + createdAt = entry.createdAt, + ) + } +} diff --git a/src/main/kotlin/com/nexters/gamss/admin/domain/AdminAccount.kt b/src/main/kotlin/com/nexters/gamss/admin/domain/AdminAccount.kt new file mode 100644 index 0000000..f8019de --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/admin/domain/AdminAccount.kt @@ -0,0 +1,43 @@ +package com.nexters.gamss.admin.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EntityListeners +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.Instant + +/** + * 백오피스 접근이 허용된 관리자 계정. 식별자는 정규화된 이메일(공백 제거·소문자)이며 중복은 UNIQUE로 막는다. + * `ADMIN_EMAILS` 환경 부트스트랩과 합집합으로 허용 여부를 판정한다([com.nexters.gamss.admin.service.AdminAccountService]). + * [createdByEmail]은 이 관리자를 추가한 관리자(감사용). + */ +@Entity +@Table(name = "admin_accounts") +@EntityListeners(AuditingEntityListener::class) +class AdminAccount( + @Column(name = "email", length = 255, nullable = false) + val email: String, + @Column(name = "created_by_email", length = 255) + val createdByEmail: String? = null, +) { + init { + require(email.isNotBlank()) { "관리자 이메일은 비어 있을 수 없습니다." } + // 이메일은 정규화(공백 제거·소문자)된 값만 저장한다 — unique 제약·isAllowed 조회가 정규화를 전제하므로, + // 정규화되지 않은 값이 들어오면 저장 전에 막는다(정규화 자체는 호출 측이 수행). + require(email == email.trim().lowercase()) { "관리자 이메일은 정규화(공백 제거·소문자)된 값이어야 합니다." } + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + var createdAt: Instant = Instant.now() + protected set +} diff --git a/src/main/kotlin/com/nexters/gamss/admin/repository/AdminAccountRepository.kt b/src/main/kotlin/com/nexters/gamss/admin/repository/AdminAccountRepository.kt new file mode 100644 index 0000000..123a944 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/admin/repository/AdminAccountRepository.kt @@ -0,0 +1,10 @@ +package com.nexters.gamss.admin.repository + +import com.nexters.gamss.admin.domain.AdminAccount +import org.springframework.data.jpa.repository.JpaRepository + +interface AdminAccountRepository : JpaRepository { + fun existsByEmail(email: String): Boolean + + fun findAllByOrderByCreatedAtAsc(): List +} diff --git a/src/main/kotlin/com/nexters/gamss/admin/service/AdminAccountEntry.kt b/src/main/kotlin/com/nexters/gamss/admin/service/AdminAccountEntry.kt new file mode 100644 index 0000000..f2e41e3 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/admin/service/AdminAccountEntry.kt @@ -0,0 +1,27 @@ +package com.nexters.gamss.admin.service + +import com.nexters.gamss.admin.domain.AdminAccount +import java.time.Instant + +/** + * 관리자 목록 조회용 통합 항목. ENV 부트스트랩과 DB 관리 항목을 한 목록으로 합쳐 보여준다. + * ENV 항목은 [id]가 없고 삭제할 수 없다([removable]=false). DB 항목도 본인 계정이면 삭제할 수 없다. + */ +data class AdminAccountEntry( + val id: Long?, + val email: String, + val source: AdminAccountSource, + val removable: Boolean, + val createdByEmail: String?, + val createdAt: Instant?, +) { + companion object { + fun db( + account: AdminAccount, + removable: Boolean, + ): AdminAccountEntry = + AdminAccountEntry(account.id, account.email, AdminAccountSource.DB, removable, account.createdByEmail, account.createdAt) + + fun bootstrap(email: String): AdminAccountEntry = AdminAccountEntry(null, email, AdminAccountSource.ENV, false, null, null) + } +} diff --git a/src/main/kotlin/com/nexters/gamss/admin/service/AdminAccountService.kt b/src/main/kotlin/com/nexters/gamss/admin/service/AdminAccountService.kt new file mode 100644 index 0000000..e6950de --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/admin/service/AdminAccountService.kt @@ -0,0 +1,83 @@ +package com.nexters.gamss.admin.service + +import com.nexters.gamss.admin.config.AdminProperties +import com.nexters.gamss.admin.domain.AdminAccount +import com.nexters.gamss.admin.repository.AdminAccountRepository +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 백오피스 접근 허용 관리자 관리. 허용 판정은 `ADMIN_EMAILS` 환경 부트스트랩과 DB의 합집합이다. + * 부트스트랩은 UI로 지울 수 없는 break-glass라 전 관리자가 잠기는(lockout) 상황을 막고, DB만 UI로 관리한다. + */ +@Service +class AdminAccountService( + private val adminAccountRepository: AdminAccountRepository, + private val adminProperties: AdminProperties, +) { + /** 로그인 허용 여부. 부트스트랩(ENV) 또는 DB에 있으면 허용. */ + @Transactional(readOnly = true) + fun isAllowed(email: String): Boolean { + if (adminProperties.isAllowed(email)) { + return true + } + return adminAccountRepository.existsByEmail(adminProperties.normalize(email)) + } + + /** + * ENV 부트스트랩 + DB 관리 항목을 합쳐 보여준다(ENV 먼저, 그다음 DB 추가순). + * ENV가 우선이라, ENV에도 있는 DB 항목은 목록에서 숨긴다 — 그 항목을 지워도 ENV로 계속 허용되므로 + * "삭제=즉시 차단" 안내가 어긋나기 때문. 본인 계정은 삭제할 수 없어 removable=false로 표시한다. + */ + @Transactional(readOnly = true) + fun list(currentEmail: String): List { + val bootstrap = adminProperties.bootstrapEmails() + val current = adminProperties.normalize(currentEmail) + val bootstrapEntries = bootstrap.sorted().map { AdminAccountEntry.bootstrap(it) } + val dbEntries = + adminAccountRepository + .findAllByOrderByCreatedAtAsc() + .filter { it.email !in bootstrap } + .map { AdminAccountEntry.db(it, removable = it.email != current) } + return bootstrapEntries + dbEntries + } + + /** 관리자를 추가한다. 이미 허용된(ENV·DB) 이메일이면 거부한다. */ + @Transactional + fun add( + email: String, + addedByEmail: String, + ): AdminAccount { + val normalized = adminProperties.normalize(email) + if (normalized.isBlank()) { + throw BusinessException(ErrorCode.INVALID_INPUT, "이메일이 비어 있습니다.") + } + if (isAllowed(normalized)) { + throw BusinessException(ErrorCode.ADMIN_ACCOUNT_ALREADY_EXISTS) + } + return try { + adminAccountRepository.saveAndFlush(AdminAccount(normalized, adminProperties.normalize(addedByEmail))) + } catch (e: DataIntegrityViolationException) { + throw BusinessException(ErrorCode.ADMIN_ACCOUNT_ALREADY_EXISTS, e.message) + } + } + + /** DB 관리자를 삭제한다. 자기 자신은 삭제할 수 없다(자기잠금 방지). ENV 부트스트랩은 DB에 없어 대상이 아니다. */ + @Transactional + fun remove( + id: Long, + currentEmail: String, + ) { + val account = + adminAccountRepository + .findById(id) + .orElseThrow { BusinessException(ErrorCode.ADMIN_ACCOUNT_NOT_FOUND) } + if (account.email == adminProperties.normalize(currentEmail)) { + throw BusinessException(ErrorCode.CANNOT_REMOVE_SELF) + } + adminAccountRepository.delete(account) + } +} diff --git a/src/main/kotlin/com/nexters/gamss/admin/service/AdminAccountSource.kt b/src/main/kotlin/com/nexters/gamss/admin/service/AdminAccountSource.kt new file mode 100644 index 0000000..71e2bbb --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/admin/service/AdminAccountSource.kt @@ -0,0 +1,7 @@ +package com.nexters.gamss.admin.service + +/** 허용 관리자 출처. ENV는 부트스트랩(삭제 불가), DB는 UI로 관리(삭제 가능). */ +enum class AdminAccountSource { + ENV, + DB, +} diff --git a/src/main/kotlin/com/nexters/gamss/admin/service/AdminAuthService.kt b/src/main/kotlin/com/nexters/gamss/admin/service/AdminAuthService.kt index f9df2c9..69b3263 100644 --- a/src/main/kotlin/com/nexters/gamss/admin/service/AdminAuthService.kt +++ b/src/main/kotlin/com/nexters/gamss/admin/service/AdminAuthService.kt @@ -16,6 +16,7 @@ import org.springframework.stereotype.Service class AdminAuthService( private val socialTokenVerifier: SocialTokenVerifier, private val adminProperties: AdminProperties, + private val adminAccountService: AdminAccountService, private val jwtIssuer: JwtIssuer, ) { fun login(idToken: String): String { @@ -26,7 +27,8 @@ class AdminAuthService( throw BusinessException(ErrorCode.NOT_ADMIN) } val email = user.email ?: throw BusinessException(ErrorCode.NOT_ADMIN) - if (!adminProperties.isAllowed(email)) { + // 허용 판정은 ENV 부트스트랩 ∪ DB. 백오피스에서 추가한 관리자는 재배포 없이 즉시 로그인된다. + if (!adminAccountService.isAllowed(email)) { throw BusinessException(ErrorCode.NOT_ADMIN) } return jwtIssuer.issueAdminToken(adminProperties.normalize(email)) diff --git a/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt b/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt index 1ed89e3..ebfc98c 100644 --- a/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt +++ b/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt @@ -44,6 +44,11 @@ enum class ErrorCode( // 카드 CARD_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 카드가 생성된 채팅방입니다."), CARD_GENERATION_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "카드 대사 생성에 실패했습니다. 잠시 후 다시 시도해주세요."), + + // 백오피스 관리자 + ADMIN_ACCOUNT_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 등록된 관리자입니다."), + ADMIN_ACCOUNT_NOT_FOUND(HttpStatus.NOT_FOUND, "관리자를 찾을 수 없습니다."), + CANNOT_REMOVE_SELF(HttpStatus.CONFLICT, "자기 자신은 관리자에서 삭제할 수 없습니다."), ; val code: String get() = name diff --git a/src/main/resources/db/migration/V7__admin_accounts.sql b/src/main/resources/db/migration/V7__admin_accounts.sql new file mode 100644 index 0000000..743e3ff --- /dev/null +++ b/src/main/resources/db/migration/V7__admin_accounts.sql @@ -0,0 +1,10 @@ +-- 백오피스 접근 허용 관리자(이메일). ADMIN_EMAILS 환경 부트스트랩과 합쳐 허용 판정에 쓰인다. +-- 이메일은 정규화(공백 제거·소문자)해 저장하고 중복은 UNIQUE로 막는다. created_by_email은 추가한 관리자. +CREATE TABLE admin_accounts ( + id BIGINT NOT NULL AUTO_INCREMENT, + email VARCHAR(255) NOT NULL, + created_by_email VARCHAR(255), + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_admin_accounts_email UNIQUE (email) +); diff --git a/src/test/kotlin/com/nexters/gamss/admin/domain/AdminAccountTest.kt b/src/test/kotlin/com/nexters/gamss/admin/domain/AdminAccountTest.kt new file mode 100644 index 0000000..5216066 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/admin/domain/AdminAccountTest.kt @@ -0,0 +1,29 @@ +package com.nexters.gamss.admin.domain + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class AdminAccountTest { + @Test + fun `정규화된 이메일로 생성된다`() { + val account = AdminAccount("admin@gamss.kr", "adder@gamss.kr") + + assertEquals("admin@gamss.kr", account.email) + } + + @Test + fun `이메일이 비어 있으면 생성할 수 없다`() { + assertFailsWith { AdminAccount(" ") } + } + + @Test + fun `대문자가 섞인 이메일은 정규화되지 않았으므로 생성할 수 없다`() { + assertFailsWith { AdminAccount("Admin@Gamss.kr") } + } + + @Test + fun `앞뒤 공백이 있는 이메일은 정규화되지 않았으므로 생성할 수 없다`() { + assertFailsWith { AdminAccount(" admin@gamss.kr ") } + } +} diff --git a/src/test/kotlin/com/nexters/gamss/admin/service/AdminAccountServiceTest.kt b/src/test/kotlin/com/nexters/gamss/admin/service/AdminAccountServiceTest.kt new file mode 100644 index 0000000..bac4f0a --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/admin/service/AdminAccountServiceTest.kt @@ -0,0 +1,146 @@ +package com.nexters.gamss.admin.service + +import com.nexters.gamss.admin.config.AdminProperties +import com.nexters.gamss.admin.domain.AdminAccount +import com.nexters.gamss.admin.repository.AdminAccountRepository +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.springframework.dao.DataIntegrityViolationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AdminAccountServiceTest { + private val repository = mockk() + private val adminProperties = AdminProperties(listOf("boot@gamss.kr")) + private val service = AdminAccountService(repository, adminProperties) + + @Test + fun `부트스트랩 이메일은 허용된다`() { + assertTrue(service.isAllowed("BOOT@gamss.kr")) + } + + @Test + fun `DB에 있으면 허용된다`() { + every { repository.existsByEmail("db@gamss.kr") } returns true + + assertTrue(service.isAllowed("db@gamss.kr")) + } + + @Test + fun `부트스트랩·DB 어디에도 없으면 허용되지 않는다`() { + every { repository.existsByEmail("none@x.com") } returns false + + assertFalse(service.isAllowed("none@x.com")) + } + + @Test + fun `목록은 부트스트랩과 DB를 합쳐 보여준다`() { + every { repository.findAllByOrderByCreatedAtAsc() } returns listOf(AdminAccount("db@gamss.kr", "adder@gamss.kr")) + + val result = service.list("me@gamss.kr") + + assertEquals(2, result.size) + assertEquals(AdminAccountSource.ENV, result[0].source) + assertEquals("boot@gamss.kr", result[0].email) + assertEquals(AdminAccountSource.DB, result[1].source) + assertEquals("db@gamss.kr", result[1].email) + assertTrue(result[1].removable) + } + + @Test + fun `DB에도 있는 부트스트랩 이메일은 ENV로만 표시된다`() { + every { repository.findAllByOrderByCreatedAtAsc() } returns listOf(AdminAccount("boot@gamss.kr", null)) + + val result = service.list("me@gamss.kr") + + assertEquals(1, result.size) + assertEquals(AdminAccountSource.ENV, result[0].source) + assertFalse(result[0].removable) + } + + @Test + fun `본인 계정은 삭제 불가로 표시된다`() { + every { repository.findAllByOrderByCreatedAtAsc() } returns listOf(AdminAccount("me@gamss.kr", null)) + + val result = service.list("Me@Gamss.kr") + + val dbEntry = result.first { it.source == AdminAccountSource.DB } + assertEquals("me@gamss.kr", dbEntry.email) + assertFalse(dbEntry.removable) + } + + @Test + fun `관리자를 추가하면 정규화해 저장한다`() { + every { repository.existsByEmail("new@x.com") } returns false + val saved = slot() + every { repository.saveAndFlush(capture(saved)) } answers { firstArg() } + + service.add(" New@X.com ", "Adder@Gamss.kr") + + assertEquals("new@x.com", saved.captured.email) + assertEquals("adder@gamss.kr", saved.captured.createdByEmail) + } + + @Test + fun `이미 부트스트랩으로 허용된 이메일은 추가할 수 없다`() { + val exception = assertFailsWith { service.add("boot@gamss.kr", "adder@gamss.kr") } + + assertEquals(ErrorCode.ADMIN_ACCOUNT_ALREADY_EXISTS, exception.errorCode) + } + + @Test + fun `이미 DB에 있는 이메일은 추가할 수 없다`() { + every { repository.existsByEmail("db@gamss.kr") } returns true + + val exception = assertFailsWith { service.add("db@gamss.kr", "adder@gamss.kr") } + + assertEquals(ErrorCode.ADMIN_ACCOUNT_ALREADY_EXISTS, exception.errorCode) + } + + @Test + fun `동시 추가로 유니크 위반이 나면 ALREADY_EXISTS로 변환된다`() { + every { repository.existsByEmail("new@x.com") } returns false + every { repository.saveAndFlush(any()) } throws DataIntegrityViolationException("duplicate") + + val exception = assertFailsWith { service.add("new@x.com", "adder@gamss.kr") } + + assertEquals(ErrorCode.ADMIN_ACCOUNT_ALREADY_EXISTS, exception.errorCode) + } + + @Test + fun `없는 관리자를 삭제하면 NOT_FOUND`() { + every { repository.findById(99L) } returns java.util.Optional.empty() + + val exception = assertFailsWith { service.remove(99L, "me@gamss.kr") } + + assertEquals(ErrorCode.ADMIN_ACCOUNT_NOT_FOUND, exception.errorCode) + } + + @Test + fun `자기 자신은 삭제할 수 없다`() { + every { repository.findById(1L) } returns java.util.Optional.of(AdminAccount("me@gamss.kr", null)) + + val exception = assertFailsWith { service.remove(1L, "Me@Gamss.kr") } + + assertEquals(ErrorCode.CANNOT_REMOVE_SELF, exception.errorCode) + } + + @Test + fun `다른 관리자는 삭제된다`() { + val account = AdminAccount("other@gamss.kr", null) + every { repository.findById(1L) } returns java.util.Optional.of(account) + justRun { repository.delete(account) } + + service.remove(1L, "me@gamss.kr") + + verify(exactly = 1) { repository.delete(account) } + } +} diff --git a/src/test/kotlin/com/nexters/gamss/admin/service/AdminAuthServiceTest.kt b/src/test/kotlin/com/nexters/gamss/admin/service/AdminAuthServiceTest.kt index 715bd05..1e35ae3 100644 --- a/src/test/kotlin/com/nexters/gamss/admin/service/AdminAuthServiceTest.kt +++ b/src/test/kotlin/com/nexters/gamss/admin/service/AdminAuthServiceTest.kt @@ -1,6 +1,7 @@ package com.nexters.gamss.admin.service import com.nexters.gamss.admin.config.AdminProperties +import com.nexters.gamss.admin.repository.AdminAccountRepository import com.nexters.gamss.auth.social.SocialProvider import com.nexters.gamss.auth.social.SocialTokenVerifier import com.nexters.gamss.auth.social.SocialUser @@ -17,7 +18,16 @@ class AdminAuthServiceTest { private val socialTokenVerifier = mockk() private val jwtIssuer = mockk() - private fun service(vararg emails: String) = AdminAuthService(socialTokenVerifier, AdminProperties(emails.toList()), jwtIssuer) + private fun service( + bootstrapEmails: List = emptyList(), + dbEmails: Set = emptySet(), + ): AdminAuthService { + val properties = AdminProperties(bootstrapEmails) + val accountRepository = mockk() + every { accountRepository.existsByEmail(any()) } answers { firstArg() in dbEmails } + val accountService = AdminAccountService(accountRepository, properties) + return AdminAuthService(socialTokenVerifier, properties, accountService, jwtIssuer) + } @Test fun `허용목록에 있는 이메일이면 관리자 토큰을 발급한다`() { @@ -25,7 +35,18 @@ class AdminAuthServiceTest { SocialUser("uid-1", SocialProvider.GOOGLE, "Admin@Gamss.KR") every { jwtIssuer.issueAdminToken("admin@gamss.kr") } returns "admin-token" - val token = service("admin@gamss.kr").login("idtok") + val token = service(bootstrapEmails = listOf("admin@gamss.kr")).login("idtok") + + assertEquals("admin-token", token) + } + + @Test + fun `DB에 등록된 이메일이면 관리자 토큰을 발급한다`() { + every { socialTokenVerifier.verify("idtok") } returns + SocialUser("uid-1", SocialProvider.GOOGLE, "DB-Admin@Gamss.kr") + every { jwtIssuer.issueAdminToken("db-admin@gamss.kr") } returns "admin-token" + + val token = service(dbEmails = setOf("db-admin@gamss.kr")).login("idtok") assertEquals("admin-token", token) } @@ -35,7 +56,7 @@ class AdminAuthServiceTest { every { socialTokenVerifier.verify("idtok") } returns SocialUser("uid-1", SocialProvider.GOOGLE, "intruder@evil.com") - val exception = assertFailsWith { service("admin@gamss.kr").login("idtok") } + val exception = assertFailsWith { service(bootstrapEmails = listOf("admin@gamss.kr")).login("idtok") } assertEquals(ErrorCode.NOT_ADMIN, exception.errorCode) } @@ -45,7 +66,7 @@ class AdminAuthServiceTest { every { socialTokenVerifier.verify("idtok") } returns SocialUser("uid-1", SocialProvider.APPLE, "admin@gamss.kr") - val exception = assertFailsWith { service("admin@gamss.kr").login("idtok") } + val exception = assertFailsWith { service(bootstrapEmails = listOf("admin@gamss.kr")).login("idtok") } assertEquals(ErrorCode.NOT_ADMIN, exception.errorCode) } @@ -55,7 +76,7 @@ class AdminAuthServiceTest { every { socialTokenVerifier.verify("idtok") } returns SocialUser("uid-1", SocialProvider.GOOGLE, null) - val exception = assertFailsWith { service("admin@gamss.kr").login("idtok") } + val exception = assertFailsWith { service(bootstrapEmails = listOf("admin@gamss.kr")).login("idtok") } assertEquals(ErrorCode.NOT_ADMIN, exception.errorCode) }