diff --git a/app/gateway-api/src/adminAudit.ts b/app/gateway-api/src/adminAudit.ts new file mode 100644 index 0000000..425eca2 --- /dev/null +++ b/app/gateway-api/src/adminAudit.ts @@ -0,0 +1,164 @@ +import { randomUUID } from 'node:crypto'; + +import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra'; + +export type AdminAuditOutcome = 'STARTED' | 'SUCCEEDED' | 'FAILED'; + +export interface AdminAuditEventRecord { + id: string; + correlationId: string; + actorUserId: string; + actorUsername: string; + credentialKind: string; + capability?: string; + scope?: string; + action: string; + targetType?: string; + targetId?: string; + profileName?: string; + reason?: string; + outcome: AdminAuditOutcome; + summary: Record; + errorCode?: string; + errorMessage?: string; + createdAt: string; +} + +export interface AdminAuditWrite { + correlationId: string; + actorUserId: string; + actorUsername: string; + capability?: string; + scope?: string; + action: string; + targetType?: string; + targetId?: string; + profileName?: string; + reason?: string; + outcome: AdminAuditOutcome; + summary?: Record; + errorCode?: string; + errorMessage?: string; +} + +export interface AdminAuditStore { + append(event: AdminAuditWrite): Promise; + list(input?: { + actorUserId?: string; + targetType?: string; + targetId?: string; + profileName?: string; + limit?: number; + }): Promise; +} + +type AuditDelegate = { + create(args: { data: Record }): Promise; + findMany(args: Record): Promise>>; +}; + +const asAuditDelegate = (prisma: GatewayPrismaClient): AuditDelegate | null => { + const delegate = (prisma as unknown as { adminAuditEvent?: AuditDelegate }).adminAuditEvent; + return delegate ?? null; +}; + +const toRecord = (row: Record): AdminAuditEventRecord => ({ + id: String(row.id), + correlationId: String(row.correlationId), + actorUserId: String(row.actorUserId), + actorUsername: String(row.actorUsername), + credentialKind: String(row.credentialKind), + ...(typeof row.capability === 'string' ? { capability: row.capability } : {}), + ...(typeof row.scope === 'string' ? { scope: row.scope } : {}), + action: String(row.action), + ...(typeof row.targetType === 'string' ? { targetType: row.targetType } : {}), + ...(typeof row.targetId === 'string' ? { targetId: row.targetId } : {}), + ...(typeof row.profileName === 'string' ? { profileName: row.profileName } : {}), + ...(typeof row.reason === 'string' ? { reason: row.reason } : {}), + outcome: row.outcome as AdminAuditOutcome, + summary: + row.summary && typeof row.summary === 'object' && !Array.isArray(row.summary) + ? (row.summary as Record) + : {}, + ...(typeof row.errorCode === 'string' ? { errorCode: row.errorCode } : {}), + ...(typeof row.errorMessage === 'string' ? { errorMessage: row.errorMessage } : {}), + createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt), +}); + +export const createAdminAuditStore = (prisma: GatewayPrismaClient): AdminAuditStore => ({ + async append(event) { + const delegate = asAuditDelegate(prisma); + // Partial Prisma mocks in router tests intentionally omit the audit model. + if (!delegate) return; + await delegate.create({ + data: { + ...event, + summary: (event.summary ?? {}) as GatewayPrisma.JsonObject, + }, + }); + }, + async list(input = {}) { + const delegate = asAuditDelegate(prisma); + if (!delegate) return []; + const rows = await delegate.findMany({ + where: { + ...(input.actorUserId ? { actorUserId: input.actorUserId } : {}), + ...(input.targetType ? { targetType: input.targetType } : {}), + ...(input.targetId ? { targetId: input.targetId } : {}), + ...(input.profileName ? { profileName: input.profileName } : {}), + }, + orderBy: { createdAt: 'desc' }, + take: Math.min(Math.max(input.limit ?? 100, 1), 200), + }); + return rows.map(toRecord); + }, +}); + +const REDACTED_KEYS = /password|credential|token|secret|oauth|authorization|email/i; + +export const sanitizeAdminAuditValue = (value: unknown, depth = 0): unknown => { + if (depth > 4) return '[DEPTH_LIMIT]'; + if (value === null || typeof value === 'boolean' || typeof value === 'number') return value; + if (typeof value === 'string') return value.length > 500 ? `${value.slice(0, 500)}…` : value; + if (Array.isArray(value)) return value.slice(0, 50).map((entry) => sanitizeAdminAuditValue(entry, depth + 1)); + if (!value || typeof value !== 'object') return String(value); + const result: Record = {}; + for (const [key, entry] of Object.entries(value).slice(0, 80)) { + result[key] = REDACTED_KEYS.test(key) ? '[REDACTED]' : sanitizeAdminAuditValue(entry, depth + 1); + } + return result; +}; + +export const buildAdminAuditTarget = ( + rawInput: unknown +): { + targetType?: string; + targetId?: string; + profileName?: string; + reason?: string; + scope?: string; + summary: Record; +} => { + const input = + rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) + ? (rawInput as Record) + : {}; + const userId = typeof input.userId === 'string' ? input.userId : undefined; + const profileName = typeof input.profileName === 'string' ? input.profileName : undefined; + const operationId = typeof input.id === 'string' ? input.id : undefined; + const reason = typeof input.reason === 'string' ? input.reason : undefined; + return { + ...(userId + ? { targetType: 'USER', targetId: userId } + : profileName + ? { targetType: 'PROFILE', targetId: profileName } + : operationId + ? { targetType: 'OPERATION', targetId: operationId } + : {}), + ...(profileName ? { profileName, scope: profileName } : {}), + ...(reason ? { reason } : {}), + summary: sanitizeAdminAuditValue(input) as Record, + }; +}; + +export const newAdminAuditCorrelationId = (): string => randomUUID(); diff --git a/app/gateway-api/src/adminCapabilities.ts b/app/gateway-api/src/adminCapabilities.ts new file mode 100644 index 0000000..a126b78 --- /dev/null +++ b/app/gateway-api/src/adminCapabilities.ts @@ -0,0 +1,106 @@ +export type AdminCapabilityRisk = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; +export type AdminCapabilityScope = 'GLOBAL' | 'PROFILE'; + +export interface AdminCapabilityDefinition { + permission: string; + label: string; + description: string; + risk: AdminCapabilityRisk; + scope: AdminCapabilityScope; +} + +export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [ + { + permission: 'admin.notice.manage', + label: 'Gateway 공지 관리', + description: 'Gateway 전역 공지를 조회하고 변경합니다.', + risk: 'MEDIUM', + scope: 'GLOBAL', + }, + { + permission: 'admin.users.create', + label: '로컬 계정 생성', + description: '환경에서 허용한 경우 로컬 계정을 생성합니다.', + risk: 'HIGH', + scope: 'GLOBAL', + }, + { + permission: 'admin.users.manage', + label: '사용자·제재 관리', + description: '계정 복구, 제재, OAuth 유예와 예약 탈퇴를 관리합니다.', + risk: 'CRITICAL', + scope: 'GLOBAL', + }, + { + permission: 'admin.audit.read', + label: '관리자 감사 조회', + description: 'Gateway 관리자 변경 이력과 실패 기록을 조회합니다.', + risk: 'HIGH', + scope: 'GLOBAL', + }, + { + permission: 'admin.profiles.manage', + label: 'Profile 운영', + description: '지정 profile의 배포, 초기화와 runtime을 관리합니다.', + risk: 'CRITICAL', + scope: 'PROFILE', + }, + { + permission: 'admin.reset.schedule', + label: 'Profile 초기화 예약', + description: '완료된 profile의 다음 초기화를 예약합니다.', + risk: 'CRITICAL', + scope: 'PROFILE', + }, + { + permission: 'admin.resume.when-stopped', + label: '중지 Profile 재개', + description: '중지 또는 일시정지된 profile을 재개합니다.', + risk: 'HIGH', + scope: 'PROFILE', + }, + { + permission: 'admin.survey.open', + label: '게임 설문 운영', + description: '지정 profile의 게임 내 설문 화면과 API를 운영합니다.', + risk: 'MEDIUM', + scope: 'PROFILE', + }, + { + permission: 'admin.tournament', + label: '게임 대회 운영', + description: '지정 profile의 게임 내 토너먼트를 운영합니다.', + risk: 'HIGH', + scope: 'PROFILE', + }, + { + permission: 'admin.releases.manage', + label: 'Gateway 릴리스', + description: 'Gateway control plane을 배포하거나 이전 release로 전환합니다.', + risk: 'CRITICAL', + scope: 'GLOBAL', + }, +] as const; + +const CAPABILITY_BY_PERMISSION = new Map(ADMIN_CAPABILITIES.map((entry) => [entry.permission, entry])); + +export const getAdminCapability = (permission: string): AdminCapabilityDefinition | undefined => + CAPABILITY_BY_PERMISSION.get(permission); + +export const resolveAdminActionCapability = (path: string, rawInput?: unknown): string | undefined => { + if (path.endsWith('.users.createLocal')) return 'admin.users.create'; + if (path.includes('.users.')) return 'admin.users.manage'; + if (path.includes('.system.')) return 'admin.notice.manage'; + if (path.includes('.releases.')) return 'admin.releases.manage'; + if (path.endsWith('.profiles.requestAction') && rawInput && typeof rawInput === 'object') { + const action = (rawInput as { action?: unknown }).action; + if (action === 'RESET_SCHEDULED') return 'admin.reset.schedule'; + if (action === 'RESUME') return 'admin.resume.when-stopped'; + if (action === 'OPEN_SURVEY') return 'admin.survey.open'; + } + if (path.includes('.operations.') || path.includes('.profiles.')) return 'admin.profiles.manage'; + return undefined; +}; + +export const isProfileCapabilityPermission = (permission: string): boolean => + getAdminCapability(permission)?.scope === 'PROFILE'; diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 07761b6..a87b055 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -10,7 +10,15 @@ import { listScenarioPreviews, resolveGitBranchCommitSha, resolveGitCommitSha } import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js'; import { toPublicUser } from './auth/userRepository.js'; import type { AdminAuthContext } from './adminAuth.js'; +import { buildAdminAuditTarget, newAdminAuditCorrelationId, sanitizeAdminAuditValue } from './adminAudit.js'; +import { + ADMIN_CAPABILITIES, + getAdminCapability, + isProfileCapabilityPermission, + resolveAdminActionCapability, +} from './adminCapabilities.js'; import type { GatewayApiContext } from './context.js'; +import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js'; import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js'; import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; @@ -41,6 +49,7 @@ const ROLE_ADMIN_USERS_CREATE = 'admin.users.create'; const ROLE_ADMIN_PROFILES = 'admin.profiles.manage'; const ROLE_ADMIN_RELEASES = 'admin.releases.manage'; const ROLE_ADMIN_NOTICE = 'admin.notice.manage'; +const ROLE_ADMIN_AUDIT = 'admin.audit.read'; const ROLE_RESET_SCHEDULE = 'admin.reset.schedule'; const ROLE_RESUME_WHEN_STOPPED = 'admin.resume.when-stopped'; const ROLE_SURVEY_OPEN = 'admin.survey.open'; @@ -171,6 +180,19 @@ const assertRoleChangesAllowed = ( if (currentRoles.has(role) === nextRoles.has(role)) { continue; } + const parsed = splitRoleScope(role); + if (parsed.permission.startsWith(ADMIN_ROLE_PREFIX) && parsed.permission !== ADMIN_ROLE_SUPERUSER) { + const capability = getAdminCapability(parsed.permission); + if (!capability) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `Unknown administrator capability: ${role}` }); + } + if (capability.scope === 'GLOBAL' && parsed.scope !== undefined) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `Capability does not accept a scope: ${role}` }); + } + if (capability.scope === 'PROFILE' && parsed.scope === '') { + throw new TRPCError({ code: 'BAD_REQUEST', message: `Profile scope is empty: ${role}` }); + } + } if (!canManageRole(adminAuth, role)) { throw new TRPCError({ code: 'FORBIDDEN', @@ -190,9 +212,36 @@ const assertPermission = (adminAuth: AdminAuthContext, permission: string, profi }); }; +const assertTargetUserManageable = (adminAuth: AdminAuthContext, target: { id: string; roles: string[] }): void => { + if (!adminAuth.isSuperuser && target.roles.some(isRootAdminRole)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Only a superuser can change a root administrator account.', + }); + } +}; + +const assertNotSelfDestructiveAction = (adminAuth: AdminAuthContext, targetUserId: string): void => { + if (adminAuth.user.id === targetUserId) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Use account self-service instead of an administrator destructive action on yourself.', + }); + } +}; + const canCreateLocalUser = (adminAuth: AdminAuthContext): boolean => hasScopedPermission(adminAuth, ROLE_ADMIN_USERS_CREATE) || hasScopedPermission(adminAuth, ROLE_ADMIN_USERS); +const canReadProfile = (adminAuth: AdminAuthContext, profileName: string): boolean => { + if (adminAuth.isSuperuser) return true; + return adminAuth.roles.some((role) => { + const parsed = splitRoleScope(role); + if (!isProfileCapabilityPermission(parsed.permission)) return false; + return parsed.scope === undefined || parsed.scope === '*' || parsed.scope === profileName; + }); +}; + // 로컬 계정 임의 생성은 환경 설정이 켜져 있을 때만 허용한다. const assertLocalAccountEnabled = (ctx: GatewayApiContext): void => { if (ctx.adminLocalAccountEnabled) { @@ -204,7 +253,7 @@ const assertLocalAccountEnabled = (ctx: GatewayApiContext): void => { }); }; -const adminProcedure = procedure.use(async ({ ctx, next }) => { +const authenticatedAdminProcedure = procedure.use(async ({ ctx, next }) => { const adminAuth = await resolveAdminAuth(ctx as GatewayApiContext); return next({ ctx: { @@ -214,6 +263,65 @@ const adminProcedure = procedure.use(async ({ ctx, next }) => { }); }); +const adminProcedure = authenticatedAdminProcedure.use(async ({ ctx, type, path, getRawInput, next }) => { + if (type !== 'mutation') { + return next(); + } + const adminAuth = requireAdminAuth(ctx); + const rawInput = await getRawInput().catch(() => undefined); + const target = buildAdminAuditTarget(rawInput); + const correlationId = newAdminAuditCorrelationId(); + const action = path.startsWith('admin.') ? path : `admin.${path}`; + const capability = resolveAdminActionCapability(action, rawInput); + const baseEvent = { + correlationId, + actorUserId: adminAuth.user.id, + actorUsername: adminAuth.user.username, + ...(capability ? { capability } : {}), + action, + ...target, + }; + // STARTED 기록 실패 시 mutation을 시작하지 않는 fail-closed 경계입니다. + await (ctx as GatewayApiContext).adminAudit.append({ ...baseEvent, outcome: 'STARTED' }); + try { + const result = await next(); + if (!result.ok) { + await (ctx as GatewayApiContext).adminAudit + .append({ + ...baseEvent, + outcome: 'FAILED', + errorCode: result.error.code, + errorMessage: result.error.message.slice(0, 1000), + }) + .catch(() => undefined); + return result; + } + // 업무 mutation은 이미 끝났으므로 terminal 기록 장애가 재시도/중복 mutation을 + // 유발하지 않게 STARTED row를 남긴 채 원래 결과를 반환합니다. + await (ctx as GatewayApiContext).adminAudit + .append({ + ...baseEvent, + outcome: 'SUCCEEDED', + summary: { + request: target.summary, + result: sanitizeAdminAuditValue(result.data), + }, + }) + .catch(() => undefined); + return result; + } catch (error) { + await (ctx as GatewayApiContext).adminAudit + .append({ + ...baseEvent, + outcome: 'FAILED', + errorCode: error instanceof TRPCError ? error.code : 'INTERNAL_SERVER_ERROR', + errorMessage: error instanceof Error ? error.message.slice(0, 1000) : 'Unknown administrator error', + }) + .catch(() => undefined); + throw error; + } +}); + const noticeAdminProcedure = adminProcedure.use(({ ctx, next }) => { const adminAuth = requireAdminAuth(ctx); assertPermission(adminAuth, ROLE_ADMIN_NOTICE); @@ -249,6 +357,12 @@ const releaseAdminProcedure = adminProcedure.use(({ ctx, next }) => { return next(); }); +const auditAdminProcedure = adminProcedure.use(({ ctx, next }) => { + const adminAuth = requireAdminAuth(ctx); + assertPermission(adminAuth, ROLE_ADMIN_AUDIT); + return next(); +}); + const zUserLookupInput = z .object({ id: z.string().min(1).optional(), @@ -402,6 +516,34 @@ const applyMetaPatch = ( }; export const adminRouter = router({ + capabilities: router({ + list: adminProcedure.query(({ ctx }) => { + const adminAuth = requireAdminAuth(ctx); + return ADMIN_CAPABILITIES.filter( + (entry) => + adminAuth.isSuperuser || + adminAuth.roles.some((role) => { + const parsed = splitRoleScope(role); + return parsed.permission === entry.permission; + }) + ); + }), + }), + audit: router({ + list: auditAdminProcedure + .input( + z + .object({ + actorUserId: z.string().min(1).optional(), + targetType: z.string().min(1).max(64).optional(), + targetId: z.string().min(1).optional(), + profileName: z.string().min(1).max(64).optional(), + limit: z.number().int().min(1).max(200).optional(), + }) + .optional() + ) + .query(({ ctx, input }) => (ctx as GatewayApiContext).adminAudit.list(input)), + }), system: router({ getNotice: adminProcedure.query(async ({ ctx }) => { const setting = await ctx.prisma.systemSetting.findUnique({ @@ -479,18 +621,89 @@ export const adminRouter = router({ oauthType: user.oauthType, oauthId: user.oauthId, email: user.email, + kakaoVerifiedAt: user.kakaoVerifiedAt, + kakaoGraceStartedAt: user.kakaoGraceStartedAt, + kakaoGraceUntil: user.kakaoGraceUntil, profileIconResetAt: user.profileIconResetAt, + deleteAfter: user.deleteAfter, createdAt: user.createdAt, }; }), + getKakaoGracePolicies: userAdminProcedure + .input(z.object({ userId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const user = await ctx.users.findById(input.userId); + if (!user) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); + } + const profiles = await ctx.profiles.listProfiles(); + return { + kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt), + kakaoGraceStartedAt: user.kakaoGraceStartedAt, + kakaoGraceUntil: user.kakaoGraceUntil ?? null, + profiles: profiles.map((profile) => ({ + profileName: profile.profileName, + ...resolveLocalAccountProfilePolicy({ + profile: profile.profile, + profileMeta: readMetaObject(profile.meta), + defaultGraceDays: (ctx as GatewayApiContext).localAccountGraceDays, + user, + }), + })), + }; + }), + updateKakaoGrace: userAdminProcedure + .input( + z.object({ + userId: z.string().min(1), + until: z.string().datetime().nullable(), + reason: z.string().trim().min(3).max(200), + }) + ) + .mutation(async ({ ctx, input }) => { + const user = await ctx.users.findById(input.userId); + if (!user) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); + } + const adminAuth = requireAdminAuth(ctx); + assertTargetUserManageable(adminAuth, user); + const until = input.until ? new Date(input.until) : null; + if (until && user.oauthType === 'KAKAO' && user.kakaoVerifiedAt) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'A verified Kakao account does not need a grace override.', + }); + } + if (until && until.getTime() <= Date.now()) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Grace extension must end in the future.' }); + } + await ctx.users.updateKakaoGraceUntil(input.userId, until); + await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-kakao-grace-updated'); + return { kakaoGraceUntil: until?.toISOString() ?? null }; + }), + listHistory: userAdminProcedure + .input(z.object({ userId: z.string().min(1), limit: z.number().int().min(1).max(200).optional() })) + .query(({ ctx, input }) => + (ctx as GatewayApiContext).adminAudit.list({ + targetType: 'USER', + targetId: input.userId, + limit: input.limit, + }) + ), resetPassword: userAdminProcedure .input( z.object({ userId: z.string().min(1), newPassword: z.string().min(6).max(128).optional(), + reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { + const user = await ctx.users.findById(input.userId); + if (!user) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); + } + assertTargetUserManageable(requireAdminAuth(ctx), user); const password = input.newPassword ?? buildAdminPassword(); await ctx.users.updatePassword(input.userId, password); await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-password-reset'); @@ -502,6 +715,7 @@ export const adminRouter = router({ userId: z.string().min(1), roles: z.array(z.string().trim().min(1).max(128)).min(1), mode: zUserRoleMode.optional(), + reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { @@ -530,6 +744,7 @@ export const adminRouter = router({ } } const adminAuth = requireAdminAuth(ctx); + assertTargetUserManageable(adminAuth, user); assertRoleChangesAllowed(adminAuth, currentRoles, roles); const nextRoles = Array.from(roles); await ctx.users.updateRoles(input.userId, nextRoles); @@ -541,6 +756,7 @@ export const adminRouter = router({ z.object({ userId: z.string().min(1), patch: zSanctionsPatch, + reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { @@ -552,6 +768,7 @@ export const adminRouter = router({ }); } const next = applySanctionsPatch(user.sanctions, input.patch); + assertTargetUserManageable(requireAdminAuth(ctx), user); await ctx.users.updateSanctions(input.userId, next); await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-sanctions-updated'); return { sanctions: next }; @@ -562,6 +779,7 @@ export const adminRouter = router({ userId: z.string().min(1), profile: z.string().min(1).max(64), restriction: zServerRestriction.nullable(), + reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { @@ -577,6 +795,7 @@ export const adminRouter = router({ [input.profile]: input.restriction ?? null, }, }; + assertTargetUserManageable(requireAdminAuth(ctx), user); const next = applySanctionsPatch(user.sanctions, patch); await ctx.users.updateSanctions(input.userId, next); await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-server-restriction'); @@ -586,6 +805,7 @@ export const adminRouter = router({ .input( z.object({ userId: z.string().min(1), + reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { @@ -596,6 +816,7 @@ export const adminRouter = router({ message: 'User not found.', }); } + assertTargetUserManageable(requireAdminAuth(ctx), user); const profileIconResetAt = await ctx.users.resetProfileIcon(input.userId, new Date()); if (!profileIconResetAt) { throw new TRPCError({ @@ -613,13 +834,48 @@ export const adminRouter = router({ } return { profileIconResetAt, flushPublished }; }), + scheduleDeletion: userAdminProcedure + .input( + z.object({ + userId: z.string().min(1), + retentionDays: z.number().int().min(1).max(90).default(30), + reason: z.string().trim().min(3).max(200), + }) + ) + .mutation(async ({ ctx, input }) => { + const user = await ctx.users.findById(input.userId); + if (!user) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); + } + const adminAuth = requireAdminAuth(ctx); + assertTargetUserManageable(adminAuth, user); + assertNotSelfDestructiveAction(adminAuth, input.userId); + const deleteAfter = new Date(Date.now() + input.retentionDays * 24 * 60 * 60 * 1000); + await ctx.users.scheduleDeletion(input.userId, deleteAfter); + await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-scheduled-withdrawal'); + return { ok: true, deleteAfter: deleteAfter.toISOString() }; + }), forceDelete: userAdminProcedure .input( z.object({ userId: z.string().min(1), + confirmUsername: z.string().min(1), + reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); + if (!adminAuth.isSuperuser) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Superuser permission is required.' }); + } + assertNotSelfDestructiveAction(adminAuth, input.userId); + const user = await ctx.users.findById(input.userId); + if (!user) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); + } + if (input.confirmUsername !== user.username) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Username confirmation does not match.' }); + } await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-force-withdraw'); await ctx.users.deleteUser(input.userId); return { ok: true }; @@ -1005,7 +1261,10 @@ export const adminRouter = router({ }), profiles: router({ list: adminProcedure.query(async ({ ctx }) => { - const profiles = await ctx.profiles.listProfiles(); + const adminAuth = requireAdminAuth(ctx); + const profiles = (await ctx.profiles.listProfiles()).filter((profile) => + canReadProfile(adminAuth, profile.profileName) + ); const profileNames = profiles.map((profile) => profile.profileName); const [runtimeActions, activeOperations] = await Promise.all([ ctx.prisma.gatewayRuntimeAction.findMany({ @@ -1140,6 +1399,7 @@ export const adminRouter = router({ localAccountAccessGraceDays: z.number().int().min(0).max(365).nullable().optional(), localAccountGeneralCreationGraceDays: z.number().int().min(0).max(365).nullable().optional(), }), + reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index 5ca0280..c02a359 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -157,6 +157,15 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp } throw new Error('User not found.'); }, + async updateKakaoGraceUntil(userId: string, until: Date | null): Promise { + for (const user of usersByName.values()) { + if (user.id === userId) { + user.kakaoGraceUntil = until?.toISOString(); + return; + } + } + throw new Error('User not found.'); + }, async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise { for (const user of usersByName.values()) { if (user.id === userId) { diff --git a/app/gateway-api/src/auth/localAccountPolicy.ts b/app/gateway-api/src/auth/localAccountPolicy.ts index ba48672..cf19598 100644 --- a/app/gateway-api/src/auth/localAccountPolicy.ts +++ b/app/gateway-api/src/auth/localAccountPolicy.ts @@ -47,6 +47,10 @@ export const resolveLocalAccountProfilePolicy = (options: { const graceStartedAt = new Date(options.user.kakaoGraceStartedAt); const now = options.now ?? new Date(); const accessEndsAt = new Date(graceStartedAt.getTime() + accessGraceDays * DAY_MS); + const adminGraceUntil = options.user.kakaoGraceUntil ? new Date(options.user.kakaoGraceUntil) : null; + if (adminGraceUntil && Number.isFinite(adminGraceUntil.getTime()) && adminGraceUntil > accessEndsAt) { + accessEndsAt.setTime(adminGraceUntil.getTime()); + } const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS); const accessAllowed = kakaoVerified || bypass || now < accessEndsAt; const canCreateGeneral = kakaoVerified || bypass || (accessAllowed && now < generalCreationEndsAt); diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index 0d98d38..1dbbdc6 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -59,6 +59,7 @@ const mapUser = (row: { privacyAcceptedAt: Date | null; kakaoVerifiedAt: Date | null; kakaoGraceStartedAt: Date; + kakaoGraceUntil: Date | null; deleteAfter: Date | null; createdAt: Date; legacyData: GatewayPrisma.JsonValue; @@ -83,6 +84,7 @@ const mapUser = (row: { privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(), kakaoVerifiedAt: row.kakaoVerifiedAt?.toISOString(), kakaoGraceStartedAt: row.kakaoGraceStartedAt.toISOString(), + kakaoGraceUntil: row.kakaoGraceUntil?.toISOString(), deleteAfter: row.deleteAfter?.toISOString(), passwordHash: row.passwordHash, passwordSalt: row.passwordSalt, @@ -250,6 +252,12 @@ export const createPostgresUserRepository = ( }, }); }, + async updateKakaoGraceUntil(userId: string, until: Date | null): Promise { + await prisma.appUser.update({ + where: { id: userId }, + data: { kakaoGraceUntil: until }, + }); + }, async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise { await prisma.appUser.update({ where: { id: userId }, diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index 5a4b6a0..526e795 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -19,6 +19,7 @@ export interface UserRecord { privacyAcceptedAt?: string; kakaoVerifiedAt?: string; kakaoGraceStartedAt: string; + kakaoGraceUntil?: string; deleteAfter?: string; passwordHash: string; passwordSalt: string; @@ -120,6 +121,7 @@ export interface UserRepository { ): Promise; updateRoles(userId: string, roles: string[]): Promise; updateSanctions(userId: string, sanctions: UserSanctions): Promise; + updateKakaoGraceUntil(userId: string, until: Date | null): Promise; updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise; updateIconForDay( userId: string, diff --git a/app/gateway-api/src/context.ts b/app/gateway-api/src/context.ts index 21bde45..7fce418 100644 --- a/app/gateway-api/src/context.ts +++ b/app/gateway-api/src/context.ts @@ -13,6 +13,7 @@ import type { GatewayProfileStatusService } from './lobby/profileStatusService.j import type { GatewayPrismaClient } from '@sammo-ts/infra'; import type { AdminAuthContext } from './adminAuth.js'; import type { PasswordEnvelopeService } from './auth/passwordEnvelope.js'; +import { createAdminAuditStore, type AdminAuditStore } from './adminAudit.js'; export interface GatewayApiContext { users: UserRepository; @@ -35,6 +36,7 @@ export interface GatewayApiContext { profileStatus: GatewayProfileStatusService; requestHeaders: Record; prisma: GatewayPrismaClient; + adminAudit: AdminAuditStore; adminAuth?: AdminAuthContext; } @@ -59,6 +61,7 @@ export const createGatewayApiContext = (options: { profileStatus: GatewayProfileStatusService; requestHeaders?: Record; prisma: GatewayPrismaClient; + adminAudit?: AdminAuditStore; }): GatewayApiContext => ({ users: options.users, sessions: options.sessions, @@ -80,4 +83,5 @@ export const createGatewayApiContext = (options: { profileStatus: options.profileStatus, requestHeaders: options.requestHeaders ?? {}, prisma: options.prisma, + adminAudit: options.adminAudit ?? createAdminAuditStore(options.prisma), }); diff --git a/app/gateway-api/test/adminAudit.integration.test.ts b/app/gateway-api/test/adminAudit.integration.test.ts new file mode 100644 index 0000000..5c2ddb2 --- /dev/null +++ b/app/gateway-api/test/adminAudit.integration.test.ts @@ -0,0 +1,68 @@ +import { randomUUID } from 'node:crypto'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra'; + +import { createAdminAuditStore } from '../src/adminAudit.js'; + +const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); + +const assertDedicatedSchema = (): void => { + const expected = process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA; + const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null; + if (!expected || !expected.endsWith('_gateway_runtime_integration') || actual !== expected) { + throw new Error('Refusing to mutate a Gateway database outside the runner-owned integration schema.'); + } +}; + +integration('administrator audit PostgreSQL store', () => { + let db: GatewayPrismaClient; + let closeDb: (() => Promise) | undefined; + + beforeAll(async () => { + assertDedicatedSchema(); + const connector = createGatewayPostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + }); + + afterAll(async () => { + await closeDb?.(); + }); + + it('appends and filters immutable target history', async () => { + const audit = createAdminAuditStore(db); + const correlationId = randomUUID(); + const targetId = randomUUID(); + await audit.append({ + correlationId, + actorUserId: randomUUID(), + actorUsername: 'integration-admin', + capability: 'admin.users.manage', + action: 'admin.users.updateSanctions', + targetType: 'USER', + targetId, + reason: 'integration verification', + outcome: 'SUCCEEDED', + summary: { patch: { warningCount: 1 } }, + }); + + await expect(audit.list({ targetType: 'USER', targetId })).resolves.toEqual([ + expect.objectContaining({ + correlationId, + actorUsername: 'integration-admin', + outcome: 'SUCCEEDED', + summary: { patch: { warningCount: 1 } }, + }), + ]); + await expect( + db.adminAuditEvent.update({ + where: { id: (await audit.list({ targetId }))[0]!.id }, + data: { action: 'tampered' }, + }) + ).rejects.toThrow(/append-only/); + }); +}); diff --git a/app/gateway-api/test/adminAudit.test.ts b/app/gateway-api/test/adminAudit.test.ts new file mode 100644 index 0000000..ea31d4a --- /dev/null +++ b/app/gateway-api/test/adminAudit.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { buildAdminAuditTarget, sanitizeAdminAuditValue } from '../src/adminAudit.js'; + +describe('administrator audit sanitization', () => { + it('redacts credentials recursively while preserving operational context', () => { + expect( + sanitizeAdminAuditValue({ + username: 'target', + password: 'secret', + nested: { authorization: 'Bearer token', notes: 'approved' }, + }) + ).toEqual({ + username: 'target', + password: '[REDACTED]', + nested: { authorization: '[REDACTED]', notes: 'approved' }, + }); + }); + + it('extracts a user target and reason without retaining the supplied password', () => { + expect( + buildAdminAuditTarget({ + userId: 'user-1', + reason: 'account recovery', + newPassword: 'replacement-secret', + }) + ).toMatchObject({ + targetType: 'USER', + targetId: 'user-1', + reason: 'account recovery', + summary: { newPassword: '[REDACTED]' }, + }); + }); +}); diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 84bd929..dc92628 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -18,6 +18,7 @@ import { createGatewayApiContext } from '../src/context.js'; import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js'; import { appRouter } from '../src/router.js'; import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js'; +import type { AdminAuditEventRecord, AdminAuditWrite } from '../src/adminAudit.js'; const buildCaller = async ( createOperation: GatewayProfileRepository['createOperation'], @@ -50,6 +51,7 @@ const buildCaller = async ( const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = []; const updatedStatuses: GatewayProfileRecord['status'][] = []; const updatedMetas: Record[] = []; + const auditEvents: AdminAuditEventRecord[] = []; let reconcileCount = 0; let storedNotice = options.initialNotice ?? ''; const profile = { @@ -162,6 +164,29 @@ const buildCaller = async ( localRegistrationEnabled: true, localAccountGraceDays: 7, passwordEnvelope: createPasswordEnvelopeService(), + adminAudit: { + append: async (event: AdminAuditWrite) => { + auditEvents.push({ + id: `audit-${auditEvents.length + 1}`, + credentialKind: 'SESSION', + createdAt: new Date(1_700_000_000_000 + auditEvents.length).toISOString(), + summary: {}, + ...event, + }); + }, + list: async (input = {}) => + auditEvents + .filter( + (event) => + (!input.actorUserId || event.actorUserId === input.actorUserId) && + (!input.targetType || event.targetType === input.targetType) && + (!input.targetId || event.targetId === input.targetId) && + (!input.profileName || event.profileName === input.profileName) + ) + .slice() + .reverse() + .slice(0, input.limit ?? 100), + }, profiles, releases, orchestrator: { @@ -225,6 +250,7 @@ const buildCaller = async ( flushes, updatedStatuses, updatedMetas, + auditEvents, getReconcileCount: () => reconcileCount, getStoredNotice: () => storedNotice, setStoredNotice: (notice: string) => { @@ -653,6 +679,7 @@ describe('admin role non-escalation', () => { userId: target.id, roles: ['admin.survey.open:che:default'], mode: 'grant', + reason: '권한 부여 테스트', }) ).resolves.toMatchObject({ roles: ['user', 'admin.survey.open:che:default'], @@ -677,6 +704,7 @@ describe('admin role non-escalation', () => { userId: target.id, roles: [role], mode: 'grant', + reason: '권한 범위 거부 테스트', }) ).rejects.toMatchObject({ code: 'FORBIDDEN' }); } @@ -699,6 +727,7 @@ describe('admin role non-escalation', () => { userId: target.id, roles: ['user'], mode: 'set', + reason: '권한 제거 거부 테스트', }) ).rejects.toMatchObject({ code: 'FORBIDDEN' }); }); @@ -714,6 +743,7 @@ describe('admin role non-escalation', () => { userId: harness.admin.id, roles: ['admin.survey.open:*'], mode: 'grant', + reason: '자기 권한 상승 거부 테스트', }) ).rejects.toMatchObject({ code: 'FORBIDDEN' }); expect((await harness.users.findById(harness.admin.id))?.roles).toEqual([ @@ -736,6 +766,7 @@ describe('admin role non-escalation', () => { userId: target.id, roles: ['superuser'], mode: 'grant', + reason: '최고 관리자 권한 부여 테스트', }) ).resolves.toMatchObject({ roles: ['user', 'superuser'] }); }); @@ -752,15 +783,18 @@ describe('admin role non-escalation', () => { userId: target.id, roles: ['admin.survey.open:che:default'], mode: 'grant', + reason: '세션 무효화 권한 테스트', }); await harness.caller.admin.users.updateSanctions({ userId: target.id, patch: { suspendedUntil: '2099-01-01T00:00:00.000Z' }, + reason: '세션 무효화 제재 테스트', }); await harness.caller.admin.users.setServerRestriction({ userId: target.id, profile: 'che:default', restriction: { blockedFeatures: ['login'] }, + reason: '세션 무효화 서버 제한 테스트', }); expect(harness.flushes).toEqual([ @@ -793,7 +827,10 @@ describe('admin role non-escalation', () => { } as never) ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); - const result = await harness.caller.admin.users.resetProfileIcon({ userId: target.id }); + const result = await harness.caller.admin.users.resetProfileIcon({ + userId: target.id, + reason: '프로필 아이콘 초기화 테스트', + }); expect(new Date(result.profileIconResetAt).getTime()).toBeGreaterThan(new Date(second!).getTime()); expect((await harness.users.findById(target.id))?.profileIconResetAt).toBe(result.profileIconResetAt); expect(harness.flushes.at(-1)).toEqual({ @@ -803,3 +840,133 @@ describe('admin role non-escalation', () => { }); }); }); + +describe('Gateway administrator account controls', () => { + const unusedCreateOperation: GatewayProfileRepository['createOperation'] = async () => { + throw new Error('not used'); + }; + + it('records sanitized STARTED and SUCCEEDED events and exposes target history', async () => { + const harness = await buildCaller(unusedCreateOperation); + const target = await harness.users.createUser({ + username: 'audit-target', + password: 'secretpass', + displayName: 'Audit Target', + }); + + await harness.caller.admin.users.resetPassword({ + userId: target.id, + newPassword: 'replacement-secret', + reason: '사용자 요청에 따른 복구', + }); + + expect(harness.auditEvents).toHaveLength(2); + expect(harness.auditEvents.map((event) => event.outcome)).toEqual(['STARTED', 'SUCCEEDED']); + expect(harness.auditEvents[0]).toMatchObject({ + actorUserId: harness.admin.id, + capability: 'admin.users.manage', + targetType: 'USER', + targetId: target.id, + reason: '사용자 요청에 따른 복구', + summary: { newPassword: '[REDACTED]' }, + }); + const history = await harness.caller.admin.users.listHistory({ userId: target.id }); + expect(history.map((event) => event.outcome)).toEqual(['SUCCEEDED', 'STARTED']); + await expect(harness.caller.admin.audit.list({ targetId: target.id })).resolves.toHaveLength(2); + }); + + it('records a FAILED terminal event when validation rejects an unknown capability', async () => { + const harness = await buildCaller(unusedCreateOperation); + const target = await harness.users.createUser({ + username: 'failed-audit-target', + password: 'secretpass', + displayName: 'Failed Audit Target', + }); + + await expect( + harness.caller.admin.users.updateRoles({ + userId: target.id, + roles: ['admin.unknown.manage'], + mode: 'grant', + reason: '알 수 없는 권한 거부 확인', + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(harness.auditEvents.map((event) => event.outcome)).toEqual(['STARTED', 'FAILED']); + expect(harness.auditEvents.at(-1)).toMatchObject({ errorCode: 'BAD_REQUEST' }); + expect((await harness.users.findById(target.id))?.roles).toEqual(['user']); + }); + + it('extends an unverified local account grace period and flushes active sessions', async () => { + const harness = await buildCaller(unusedCreateOperation); + const target = await harness.users.createUser({ + username: 'grace-target', + password: 'secretpass', + displayName: 'Grace Target', + }); + const until = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(); + + await expect( + harness.caller.admin.users.updateKakaoGrace({ + userId: target.id, + until, + reason: '고객센터 본인 확인 처리 중', + }) + ).resolves.toEqual({ kakaoGraceUntil: until }); + expect((await harness.users.findById(target.id))?.kakaoGraceUntil).toBe(until); + expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-kakao-grace-updated' }); + }); + + it('schedules deletion with retention and prevents administrator self-deletion', async () => { + const harness = await buildCaller(unusedCreateOperation); + const target = await harness.users.createUser({ + username: 'deletion-target', + password: 'secretpass', + displayName: 'Deletion Target', + }); + + const result = await harness.caller.admin.users.scheduleDeletion({ + userId: target.id, + retentionDays: 30, + reason: '탈퇴 요청 증빙 확인 완료', + }); + expect((await harness.users.findById(target.id))?.deleteAfter).toBe(result.deleteAfter); + await expect( + harness.caller.admin.users.scheduleDeletion({ + userId: harness.admin.id, + retentionDays: 30, + reason: '관리자 자기 삭제 차단 확인', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + + it('prevents a delegated administrator from changing a root administrator', async () => { + const harness = await buildCaller(unusedCreateOperation, { + adminRoles: ['user', 'admin.users.manage'], + firstUserIsAdmin: false, + }); + const target = await harness.users.createUser({ + username: 'root-target', + password: 'secretpass', + displayName: 'Root Target', + }); + await harness.users.updateRoles(target.id, ['user', 'admin']); + + await expect( + harness.caller.admin.users.updateSanctions({ + userId: target.id, + patch: { suspendedUntil: '2099-01-01T00:00:00.000Z' }, + reason: '루트 계정 보호 확인', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect((await harness.users.findById(target.id))?.sanctions).toEqual({}); + }); + + it('keeps the global audit feed behind its dedicated capability', async () => { + const harness = await buildCaller(unusedCreateOperation, { + adminRoles: ['user', 'admin.users.manage'], + firstUserIsAdmin: false, + }); + + await expect(harness.caller.admin.audit.list()).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); +}); diff --git a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts index c8f8bc5..76ee313 100644 --- a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts +++ b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts @@ -188,6 +188,7 @@ describe('admin security over HTTP transport', () => { userId: harness.target.id, roles: ['admin.survey.open:che:default'], mode: 'grant', + reason: 'HTTP 권한 부여 테스트', }, harness.adminSessionToken ); @@ -209,6 +210,7 @@ describe('admin security over HTTP transport', () => { userId: harness.target.id, roles: ['admin.survey.open:*'], mode: 'grant', + reason: 'HTTP 권한 상승 거부 테스트', }, harness.adminSessionToken ); @@ -233,6 +235,7 @@ describe('admin security over HTTP transport', () => { userId: harness.target.id, roles: ['admin.survey.open:che:default'], mode: 'grant', + reason: '미인증 권한 변경 거부 테스트', }); expect(rejected.response.status).toBe(401); @@ -256,6 +259,7 @@ describe('admin security over HTTP transport', () => { userId: harness.admin.id, roles: ['admin.survey.open:*'], mode: 'grant', + reason: '자기 권한 상승 거부 테스트', }, harness.adminSessionToken ); @@ -274,6 +278,7 @@ describe('admin security over HTTP transport', () => { userId: harness.target.id, roles: ['user'], mode: 'set', + reason: '범위 밖 권한 제거 거부 테스트', }, harness.adminSessionToken ); @@ -291,6 +296,7 @@ describe('admin security over HTTP transport', () => { userId: harness.target.id, roles: ['superuser'], mode: 'grant', + reason: '최고 관리자 권한 부여 테스트', }, harness.adminSessionToken ); @@ -310,6 +316,7 @@ describe('admin security over HTTP transport', () => { patch: { suspendedUntil: '2099-01-01T00:00:00.000Z', }, + reason: 'HTTP 제재 적용 테스트', }, harness.adminSessionToken ); diff --git a/app/gateway-api/test/localAccountPolicy.test.ts b/app/gateway-api/test/localAccountPolicy.test.ts index 85746f4..9b3df57 100644 --- a/app/gateway-api/test/localAccountPolicy.test.ts +++ b/app/gateway-api/test/localAccountPolicy.test.ts @@ -89,4 +89,22 @@ describe('local account profile policy', () => { graceEndsAt: null, }); }); + + it('extends account access with an administrator override without widening general creation grace', () => { + const user = buildLocalUser(new Date('2026-07-20T00:00:00.000Z')); + user.kakaoGraceUntil = '2026-08-20T00:00:00.000Z'; + const policy = resolveLocalAccountProfilePolicy({ + profile: 'che', + defaultGraceDays: 7, + user, + now: new Date('2026-08-01T00:00:00.000Z'), + }); + + expect(policy).toMatchObject({ + accessAllowed: true, + canCreateGeneral: false, + graceEndsAt: '2026-08-20T00:00:00.000Z', + generalCreationGraceDays: 0, + }); + }); }); diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 4ac3d57..30e4442 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -37,7 +37,7 @@ describe('readReleaseManifest', () => { const workspaceRoot = path.resolve(import.meta.dirname, '../../..'); await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ - gatewaySchemaHead: '20260801000000_add_user_icon_library', + gatewaySchemaHead: '20260806000000_add_admin_audit_and_kakao_grace', gameSchemaHead: '20260803000000_add_logical_game_clock', }); }); diff --git a/app/gateway-frontend/e2e/admin-account-controls.spec.ts b/app/gateway-frontend/e2e/admin-account-controls.spec.ts new file mode 100644 index 0000000..c7fa2f8 --- /dev/null +++ b/app/gateway-frontend/e2e/admin-account-controls.spec.ts @@ -0,0 +1,178 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { writeFile } from 'node:fs/promises'; + +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const installFixture = async (page: Page) => { + const mutations: Array<{ operation: string; body: unknown }> = []; + let deleteAfter: string | null = null; + let graceUntil: string | null = null; + const auditHistory = [ + { + id: 'audit-1', + correlationId: 'correlation-1', + actorUsername: 'admin', + action: 'admin.users.updateSanctions', + outcome: 'SUCCEEDED', + reason: '기존 제재 사유', + summary: {}, + createdAt: '2026-08-06T01:00:00.000Z', + }, + ]; + await page.addInitScript(() => { + window.localStorage.setItem('sammo-session-token', 'playwright-admin-session'); + }); + await page.route('**/gateway/api/trpc/**', async (route) => { + const operations = operationNames(route); + const body = route.request().postDataJSON() as unknown; + const results = operations.map((operation) => { + if (route.request().method() === 'POST') mutations.push({ operation, body }); + if (operation === 'me') { + return response({ + id: 'admin-user', + username: 'admin', + displayName: '관리자', + roles: ['superuser'], + createdAt: '2026-08-01T00:00:00.000Z', + }); + } + if (operation === 'admin.capabilities.list') { + return response([ + { + permission: 'admin.users.manage', + label: '사용자·제재 관리', + description: '계정 복구, 제재, OAuth 유예와 예약 탈퇴를 관리합니다.', + risk: 'CRITICAL', + scope: 'GLOBAL', + }, + { + permission: 'admin.profiles.manage', + label: 'Profile 운영', + description: '지정 profile을 관리합니다.', + risk: 'CRITICAL', + scope: 'PROFILE', + }, + { + permission: 'admin.audit.read', + label: '관리자 감사 조회', + description: 'Gateway 관리자 변경 이력을 조회합니다.', + risk: 'HIGH', + scope: 'GLOBAL', + }, + ]); + } + if (operation === 'admin.audit.list') return response(auditHistory); + if (operation === 'admin.users.getLocalAccountStatus') return response({ enabled: false }); + if (operation === 'admin.system.getNotice') return response({ notice: '' }); + if (operation === 'admin.profiles.list') return response([]); + if (operation === 'admin.profiles.listScenarios') return response([]); + if (operation === 'admin.users.lookup') { + return response({ + id: 'target-user', + username: 'target', + displayName: '대상 사용자', + roles: ['user'], + sanctions: {}, + oauthType: 'NONE', + kakaoGraceStartedAt: '2026-07-20T00:00:00.000Z', + kakaoGraceUntil: graceUntil, + deleteAfter, + createdAt: '2026-07-20T00:00:00.000Z', + }); + } + if (operation === 'admin.users.getKakaoGracePolicies') { + return response({ + kakaoVerified: false, + kakaoGraceStartedAt: '2026-07-20T00:00:00.000Z', + kakaoGraceUntil: graceUntil, + profiles: [ + { + profileName: 'che:default', + requiresKakaoVerification: true, + kakaoVerified: false, + accessAllowed: true, + canCreateGeneral: false, + graceEndsAt: graceUntil ?? '2026-08-10T00:00:00.000Z', + generalCreationGraceDays: 0, + accessGraceDays: 7, + }, + ], + }); + } + if (operation === 'admin.users.listHistory') return response(auditHistory); + if (operation === 'admin.users.updateKakaoGrace') { + graceUntil = '2026-08-20T00:00:00.000Z'; + auditHistory.unshift({ + ...auditHistory[0], + id: 'audit-2', + action: 'admin.users.updateKakaoGrace', + reason: '본인 확인 처리 중', + }); + return response({ kakaoGraceUntil: graceUntil }); + } + if (operation === 'admin.users.scheduleDeletion') { + deleteAfter = '2026-09-05T00:00:00.000Z'; + auditHistory.unshift({ + ...auditHistory[0], + id: 'audit-3', + action: 'admin.users.scheduleDeletion', + reason: '탈퇴 요청 접수', + }); + return response({ ok: true, deleteAfter }); + } + throw new Error(`Unhandled tRPC operation: ${operation}`); + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); + return mutations; +}; + +test('operates OAuth grace and scheduled deletion with reasoned audit history', async ({ page }, testInfo) => { + const mutations = await installFixture(page); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('admin'); + await page.getByPlaceholder('검색 값 입력').fill('target'); + await page.getByRole('button', { name: '조회', exact: true }).click(); + + await expect(page.getByText('Kakao 인증: 미완료')).toBeVisible(); + await expect(page.getByRole('cell', { name: 'che:default' })).toBeVisible(); + await expect(page.getByText('SUCCEEDED · admin.users.updateSanctions').first()).toBeVisible(); + await expect(page.getByRole('heading', { name: '전체 관리자 감사 원장' })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-desktop.png'), fullPage: true }); + const deletionButton = page.getByRole('button', { name: '보존 기간 후 탈퇴 예약', exact: true }); + const baseDeleteColor = await deletionButton.evaluate((button) => getComputedStyle(button).backgroundColor); + await deletionButton.hover(); + await expect + .poll(() => deletionButton.evaluate((button) => getComputedStyle(button).backgroundColor)) + .not.toBe(baseDeleteColor); + await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-hover.png'), fullPage: true }); + await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중'); + await page.locator('input[type="datetime-local"]').nth(0).fill('2026-08-20T00:00'); + await page.getByRole('button', { name: '유예 연장', exact: true }).click(); + await expect(page.getByText('OAuth 유예 연장 완료')).toBeVisible(); + await expect(page.getByText('SUCCEEDED · admin.users.updateKakaoGrace').first()).toBeVisible(); + + await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('탈퇴 요청 접수'); + await page.getByLabel('탈퇴 전 보존 일수').fill('30'); + await deletionButton.click(); + await expect(page.getByText(/탈퇴 예약 완료/)).toBeVisible(); + expect(mutations.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true); + expect(mutations.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true); + + await page.setViewportSize({ width: 390, height: 844 }); + const geometry = await page + .getByRole('heading', { name: '전체 관리자 감사 원장' }) + .locator('..') + .evaluate((panel) => { + const rect = panel.getBoundingClientRect(); + return { left: rect.left, right: rect.right, width: rect.width, viewportWidth: window.innerWidth }; + }); + expect(geometry.left).toBeGreaterThanOrEqual(0); + expect(geometry.right).toBeLessThanOrEqual(geometry.viewportWidth); + await writeFile(testInfo.outputPath('gateway-admin-account-controls-mobile-geometry.json'), JSON.stringify(geometry)); + await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-mobile.png'), fullPage: true }); +}); diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index c3afddb..29a21b7 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -89,6 +89,17 @@ const installFixture = async ( if (operation === 'admin.users.getLocalAccountStatus') { return response({ enabled: true }); } + if (operation === 'admin.capabilities.list') { + return response([ + { + permission: 'admin.users.manage', + label: '사용자·제재 관리', + description: '계정 복구와 제재를 관리합니다.', + risk: 'CRITICAL', + scope: 'GLOBAL', + }, + ]); + } if (operation === 'admin.profiles.listScenarios') { return response([ { diff --git a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts index f6b5a56..4fb336f 100644 --- a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts +++ b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts @@ -40,6 +40,9 @@ const installGatewayFixture = async (page: Page, roles: string[]) => { if (operation === 'admin.users.getLocalAccountStatus') { return response({ enabled: true }); } + if (operation === 'admin.capabilities.list') { + return response([]); + } throw new Error(`Unhandled tRPC operation: ${operation}`); }); await route.fulfill({ diff --git a/app/gateway-frontend/e2e/playwright.config.mjs b/app/gateway-frontend/e2e/playwright.config.mjs index 061afa9..86e9229 100644 --- a/app/gateway-frontend/e2e/playwright.config.mjs +++ b/app/gateway-frontend/e2e/playwright.config.mjs @@ -9,6 +9,7 @@ export default defineConfig({ testMatch: [ 'server-operations.spec.ts', 'admin-runtime-actions.spec.ts', + 'admin-account-controls.spec.ts', 'lobby-admin-navigation.spec.ts', 'lobby-game-auth.spec.ts', 'logout.spec.ts', diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index cfe1391..ad3e1d9 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -48,10 +48,48 @@ type AdminUser = { oauthType: string; oauthId?: string; email?: string; + kakaoVerifiedAt?: string; + kakaoGraceStartedAt: string; + kakaoGraceUntil?: string; profileIconResetAt?: string; + deleteAfter?: string; createdAt: string; }; +type AdminCapability = { + permission: string; + label: string; + description: string; + risk: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + scope: 'GLOBAL' | 'PROFILE'; +}; + +type AdminAuditEvent = { + id: string; + correlationId: string; + actorUsername: string; + targetType?: string; + targetId?: string; + profileName?: string; + action: string; + outcome: 'STARTED' | 'SUCCEEDED' | 'FAILED'; + reason?: string; + summary: Record; + errorMessage?: string; + createdAt: string; +}; + +type KakaoGracePolicy = { + profileName: string; + requiresKakaoVerification: boolean; + kakaoVerified: boolean; + accessAllowed: boolean; + canCreateGeneral: boolean; + graceEndsAt: string | null; + generalCreationGraceDays: number; + accessGraceDays: number; +}; + type AdminPublicUser = { id: string; username: string; @@ -140,6 +178,12 @@ type AdminAction = 'RESUME' | 'PAUSE' | 'STOP' | 'ACCELERATE' | 'DELAY' | 'RESET_NOW' | 'RESET_SCHEDULED' | 'OPEN_SURVEY' | 'SHUTDOWN'; type AdminClient = { + capabilities: { + list: { query: () => Promise }; + }; + audit: { + list: { query: (input?: { limit?: number }) => Promise }; + }; system: { getNotice: { query: () => Promise<{ notice: string }>; @@ -162,20 +206,38 @@ type AdminClient = { lookup: { query: (input: { id?: string; username?: string; email?: string }) => Promise; }; + getKakaoGracePolicies: { + query: (input: { userId: string }) => Promise<{ + kakaoVerified: boolean; + kakaoGraceStartedAt: string; + kakaoGraceUntil: string | null; + profiles: KakaoGracePolicy[]; + }>; + }; + updateKakaoGrace: { + mutate: (input: { userId: string; until: string | null; reason: string }) => Promise<{ + kakaoGraceUntil: string | null; + }>; + }; + listHistory: { + query: (input: { userId: string; limit?: number }) => Promise; + }; resetPassword: { - mutate: (input: { userId: string; newPassword?: string }) => Promise<{ password: string }>; + mutate: (input: { userId: string; newPassword?: string; reason: string }) => Promise<{ password: string }>; }; updateRoles: { mutate: (input: { userId: string; roles: string[]; mode?: 'set' | 'grant' | 'revoke'; + reason: string; }) => Promise<{ roles: string[] }>; }; updateSanctions: { mutate: (input: { userId: string; patch: AdminSanctionsPatch; + reason: string; }) => Promise<{ sanctions: AdminUserSanctions }>; }; setServerRestriction: { @@ -188,16 +250,20 @@ type AdminClient = { reason?: string | null; notes?: string | null; } | null; + reason: string; }) => Promise<{ sanctions: AdminUserSanctions }>; }; resetProfileIcon: { - mutate: (input: { userId: string }) => Promise<{ + mutate: (input: { userId: string; reason: string }) => Promise<{ profileIconResetAt: string; flushPublished: boolean; }>; }; - forceDelete: { - mutate: (input: { userId: string }) => Promise<{ ok: boolean }>; + scheduleDeletion: { + mutate: (input: { userId: string; retentionDays: number; reason: string }) => Promise<{ + ok: boolean; + deleteAfter: string; + }>; }; }; profiles: { @@ -216,7 +282,10 @@ type AdminClient = { inGameNotice?: string | null; profileImageUrl?: string | null; nextSeasonIdx?: number | null; + localAccountAccessGraceDays?: number | null; + localAccountGeneralCreationGraceDays?: number | null; }; + reason: string; }) => Promise; }; install: { @@ -292,6 +361,9 @@ const profileEdits = ref< inGameNotice: string; profileImageUrl: string; nextSeasonIdx: string; + localAccountAccessGraceDays: string; + localAccountGeneralCreationGraceDays: string; + reason: string; } > >({}); @@ -371,6 +443,10 @@ const passwordStatus = ref(''); const rolesInput = ref(''); const rolesMode = ref<'set' | 'grant' | 'revoke'>('grant'); const rolesStatus = ref(''); +const capabilities = ref([]); +const selectedCapability = ref(''); +const capabilityProfile = ref(''); +const userActionReason = ref(''); const banUntil = ref(''); const banReason = ref(''); @@ -386,6 +462,13 @@ const restrictionNotes = ref(''); const restrictionStatus = ref(''); const forceDeleteStatus = ref(''); +const deletionRetentionDays = ref(30); +const kakaoGraceUntil = ref(''); +const kakaoGraceStatus = ref(''); +const kakaoPolicies = ref([]); +const userHistory = ref([]); +const globalAuditHistory = ref([]); +const globalAuditStatus = ref(''); const hasUser = computed(() => Boolean(userResult.value)); @@ -441,6 +524,15 @@ const ensureProfileBuffers = (profile: AdminProfile) => { typeof meta.nextSeasonIdx === 'number' && Number.isFinite(meta.nextSeasonIdx) ? String(Math.floor(meta.nextSeasonIdx)) : '', + localAccountAccessGraceDays: + typeof meta.localAccountAccessGraceDays === 'number' + ? String(Math.floor(meta.localAccountAccessGraceDays)) + : '', + localAccountGeneralCreationGraceDays: + typeof meta.localAccountGeneralCreationGraceDays === 'number' + ? String(Math.floor(meta.localAccountGeneralCreationGraceDays)) + : '', + reason: '', }; } if (!profileActions.value[profile.profileName]) { @@ -711,17 +803,38 @@ const updateProfileMeta = async (profileName: string) => { }; return; } + const readGraceDays = (value: string): number | null => { + if (!value.trim()) return null; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 && parsed <= 365 ? parsed : Number.NaN; + }; + const accessGraceDays = readGraceDays(edit.localAccountAccessGraceDays); + const creationGraceDays = readGraceDays(edit.localAccountGeneralCreationGraceDays); + if (Number.isNaN(accessGraceDays) || Number.isNaN(creationGraceDays)) { + profileActionStatus.value = { + ...profileActionStatus.value, + [profileName]: 'Kakao 유예일은 0~365 사이 정수여야 합니다.', + }; + return; + } + if (edit.reason.trim().length < 3) { + profileActionStatus.value = { ...profileActionStatus.value, [profileName]: '변경 사유를 입력하세요.' }; + return; + } const patch = { korName: edit.korName.trim() || null, color: edit.color.trim() || null, inGameNotice: edit.inGameNotice.trim() || null, profileImageUrl: edit.profileImageUrl.trim() || null, nextSeasonIdx: nextSeasonIdx === null ? null : Math.floor(nextSeasonIdx), + localAccountAccessGraceDays: accessGraceDays, + localAccountGeneralCreationGraceDays: creationGraceDays, }; try { const updated = await adminClient.profiles.updateMeta.mutate({ profileName, patch, + reason: edit.reason.trim(), }); profileActionStatus.value = { ...profileActionStatus.value, @@ -890,6 +1003,13 @@ const lookupUser = async () => { return; } userResult.value = result; + const [grace, history] = await Promise.all([ + adminClient.users.getKakaoGracePolicies.query({ userId: result.id }), + adminClient.users.listHistory.query({ userId: result.id, limit: 50 }), + ]); + kakaoPolicies.value = grace.profiles; + kakaoGraceUntil.value = grace.kakaoGraceUntil ? toLocalInputValue(grace.kakaoGraceUntil) : ''; + userHistory.value = history; } catch (error) { userError.value = '조회 실패'; } finally { @@ -897,20 +1017,95 @@ const lookupUser = async () => { } }; +const requireUserActionReason = (): string | null => { + const reason = userActionReason.value.trim(); + if (reason.length < 3) { + userError.value = '민감한 관리자 조치에는 3자 이상의 사유가 필요합니다.'; + return null; + } + return reason; +}; + +const loadGlobalAudit = async () => { + if (!capabilities.value.some((entry) => entry.permission === 'admin.audit.read')) return; + try { + globalAuditHistory.value = await adminClient.audit.list.query({ limit: 100 }); + globalAuditStatus.value = ''; + } catch { + globalAuditStatus.value = '감사 원장을 불러오지 못했습니다.'; + } +}; + +const refreshUserHistory = async () => { + if (!userResult.value) return; + userHistory.value = await adminClient.users.listHistory.query({ userId: userResult.value.id, limit: 50 }); + await loadGlobalAudit(); +}; + +const loadCapabilities = async () => { + try { + capabilities.value = await adminClient.capabilities.list.query(); + selectedCapability.value = capabilities.value[0]?.permission ?? ''; + await loadGlobalAudit(); + } catch { + capabilities.value = []; + } +}; + +const applyCapabilitySelection = () => { + const capability = capabilities.value.find((entry) => entry.permission === selectedCapability.value); + if (!capability) return; + if (capability.scope === 'PROFILE' && !capabilityProfile.value.trim()) { + rolesStatus.value = 'Profile 범위를 입력하세요.'; + return; + } + rolesInput.value = + capability.scope === 'PROFILE' + ? `${capability.permission}:${capabilityProfile.value.trim()}` + : capability.permission; +}; + +const updateKakaoGrace = async (clear = false) => { + if (!userResult.value) return; + const reason = requireUserActionReason(); + if (!reason) return; + try { + const result = await adminClient.users.updateKakaoGrace.mutate({ + userId: userResult.value.id, + until: clear || !kakaoGraceUntil.value ? null : new Date(kakaoGraceUntil.value).toISOString(), + reason, + }); + userResult.value = { + ...userResult.value, + kakaoGraceUntil: result.kakaoGraceUntil ?? undefined, + }; + kakaoGraceStatus.value = result.kakaoGraceUntil ? 'OAuth 유예 연장 완료' : '개별 유예 해제 완료'; + const grace = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id }); + kakaoPolicies.value = grace.profiles; + await refreshUserHistory(); + } catch { + kakaoGraceStatus.value = 'OAuth 유예 변경 실패'; + } +}; + const resetUserPassword = async () => { if (!userResult.value) { return; } + const reason = requireUserActionReason(); + if (!reason) return; passwordStatus.value = ''; passwordResult.value = ''; try { const result = await adminClient.users.resetPassword.mutate({ userId: userResult.value.id, newPassword: passwordInput.value.trim() || undefined, + reason, }); passwordResult.value = result.password; passwordStatus.value = '초기화 완료'; passwordInput.value = ''; + await refreshUserHistory(); } catch (error) { passwordStatus.value = '초기화 실패'; } @@ -920,6 +1115,8 @@ const updateUserRoles = async () => { if (!userResult.value) { return; } + const reason = requireUserActionReason(); + if (!reason) return; const roles = rolesInput.value .split(',') .map((role) => role.trim()) @@ -934,9 +1131,11 @@ const updateUserRoles = async () => { userId: userResult.value.id, roles, mode: rolesMode.value, + reason, }); userResult.value = { ...userResult.value, roles: result.roles }; rolesStatus.value = '권한 업데이트 완료'; + await refreshUserHistory(); } catch (error) { rolesStatus.value = '권한 업데이트 실패'; } @@ -946,6 +1145,8 @@ const applyBan = async () => { if (!userResult.value) { return; } + const reason = requireUserActionReason(); + if (!reason) return; const until = banUntil.value ? new Date(banUntil.value).toISOString() : null; const patch = { bannedUntil: until, @@ -955,9 +1156,11 @@ const applyBan = async () => { const result = await adminClient.users.updateSanctions.mutate({ userId: userResult.value.id, patch, + reason, }); userResult.value = { ...userResult.value, sanctions: result.sanctions }; banStatus.value = '차단 설정 완료'; + await refreshUserHistory(); } catch (error) { banStatus.value = '차단 설정 실패'; } @@ -967,13 +1170,17 @@ const clearBan = async () => { if (!userResult.value) { return; } + const reason = requireUserActionReason(); + if (!reason) return; try { const result = await adminClient.users.updateSanctions.mutate({ userId: userResult.value.id, patch: { bannedUntil: null }, + reason, }); userResult.value = { ...userResult.value, sanctions: result.sanctions }; banStatus.value = '차단 해제 완료'; + await refreshUserHistory(); } catch (error) { banStatus.value = '차단 해제 실패'; } @@ -983,9 +1190,12 @@ const resetProfileIcon = async () => { if (!userResult.value) { return; } + const reason = requireUserActionReason(); + if (!reason) return; try { const result = await adminClient.users.resetProfileIcon.mutate({ userId: userResult.value.id, + reason, }); userResult.value = { ...userResult.value, @@ -994,6 +1204,7 @@ const resetProfileIcon = async () => { profileIconStatus.value = result.flushPublished ? '아이콘 초기화 요청 완료' : '아이콘은 초기화됐지만 실행 중 서버 알림에 실패했습니다. 다시 요청해 주세요.'; + await refreshUserHistory(); } catch (error) { profileIconStatus.value = '아이콘 초기화 실패'; } @@ -1003,6 +1214,8 @@ const applyRestriction = async () => { if (!userResult.value) { return; } + const reason = requireUserActionReason(); + if (!reason) return; if (!restrictionProfile.value.trim()) { restrictionStatus.value = '서버 프로필명을 입력하세요.'; return; @@ -1022,9 +1235,11 @@ const applyRestriction = async () => { userId: userResult.value.id, profile: restrictionProfile.value.trim(), restriction, + reason, }); userResult.value = { ...userResult.value, sanctions: result.sanctions }; restrictionStatus.value = '서버 제재 적용 완료'; + await refreshUserHistory(); } catch (error) { restrictionStatus.value = '서버 제재 적용 실패'; } @@ -1034,6 +1249,8 @@ const clearRestriction = async () => { if (!userResult.value) { return; } + const reason = requireUserActionReason(); + if (!reason) return; if (!restrictionProfile.value.trim()) { restrictionStatus.value = '서버 프로필명을 입력하세요.'; return; @@ -1043,30 +1260,39 @@ const clearRestriction = async () => { userId: userResult.value.id, profile: restrictionProfile.value.trim(), restriction: null, + reason, }); userResult.value = { ...userResult.value, sanctions: result.sanctions }; restrictionStatus.value = '서버 제재 해제 완료'; + await refreshUserHistory(); } catch (error) { restrictionStatus.value = '서버 제재 해제 실패'; } }; -const forceDeleteUser = async () => { +const scheduleDeleteUser = async () => { if (!userResult.value) { return; } + const reason = requireUserActionReason(); + if (!reason) return; if (typeof window !== 'undefined') { - const confirmed = window.confirm('정말로 강제 탈퇴 처리하시겠습니까?'); + const confirmed = window.confirm(`${deletionRetentionDays.value}일 보존 후 탈퇴하도록 예약하시겠습니까?`); if (!confirmed) { return; } } try { - await adminClient.users.forceDelete.mutate({ userId: userResult.value.id }); - userResult.value = null; - forceDeleteStatus.value = '강제 탈퇴 완료'; + const result = await adminClient.users.scheduleDeletion.mutate({ + userId: userResult.value.id, + retentionDays: deletionRetentionDays.value, + reason, + }); + userResult.value = { ...userResult.value, deleteAfter: result.deleteAfter }; + forceDeleteStatus.value = `탈퇴 예약 완료: ${new Date(result.deleteAfter).toLocaleString('ko-KR')}`; + await refreshUserHistory(); } catch (error) { - forceDeleteStatus.value = '강제 탈퇴 실패'; + forceDeleteStatus.value = '탈퇴 예약 실패'; } }; @@ -1109,6 +1335,7 @@ const createLocalAccount = async () => { }; onMounted(() => { + void loadCapabilities(); void loadLocalAccountStatus(); void loadNotice(); void loadProfiles(); @@ -1153,7 +1380,7 @@ onMounted(() => {
-
+

유저 관리

@@ -1192,6 +1419,16 @@ onMounted(() => {
OAuth: {{ userResult.oauthType }} {{ userResult.email ?? '' }}
+
+ Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작: + {{ new Date(userResult.kakaoGraceStartedAt).toLocaleString('ko-KR') }} +
+
+ 관리자 유예: {{ new Date(userResult.kakaoGraceUntil).toLocaleString('ko-KR') }}까지 +
+
+ 탈퇴 예약: {{ new Date(userResult.deleteAfter).toLocaleString('ko-KR') }} +
가입일: {{ userResult.createdAt }}
제재 상태
 {
                         
+
+

민감 조치 공통 사유

+ +
사유와 정화된 입력은 관리자 감사 원장에 기록됩니다.
+
+

로컬 계정 생성

@@ -1266,6 +1515,38 @@ onMounted(() => {

특수 권한 부여

+
+ + + +
+
+ {{ capabilities.find((item) => item.permission === selectedCapability)?.description }} +
+ + +
+
{{ kakaoGraceStatus }}
+
+ + + + + + + + + + + + + + + + + + + +
Profile접근장수 생성기본 접근 유예종료
{{ policy.profileName }}{{ policy.accessAllowed ? '허용' : '차단' }}{{ policy.canCreateGeneral ? '허용' : '차단' }}{{ policy.accessGraceDays }}일 + {{ + policy.graceEndsAt + ? new Date(policy.graceEndsAt).toLocaleString('ko-KR') + : '-' + }} +
+
+
+

유저 차단

@@ -1400,19 +1743,109 @@ onMounted(() => {
-

강제 탈퇴

+

관리자 탈퇴 예약

+
{{ forceDeleteStatus }}
+ +
+

사용자 관리자 조치 이력

+
기록이 없습니다.
+
+
+
+ + {{ event.outcome }} · {{ event.action }} + + {{ + new Date(event.createdAt).toLocaleString('ko-KR') + }} +
+
+ {{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }} +
+
{{ event.errorMessage }}
+
{{
+                                    JSON.stringify(event.summary, null, 2)
+                                }}
+
+
+
-
+
+
+
+

전체 관리자 감사 원장

+ +
+
{{ globalAuditStatus }}
+
기록이 없습니다.
+
+
+
+ + {{ event.outcome }} · {{ event.action }} + + {{ + new Date(event.createdAt).toLocaleString('ko-KR') + }} +
+
+ {{ event.actorUsername }} · {{ event.targetType ?? '-' }} + {{ event.targetId ?? event.profileName ?? '' }} · {{ event.reason ?? '사유 없음' }} +
+
+
+
+

서버 공지

@@ -1510,6 +1943,36 @@ onMounted(() => { placeholder="예: 12" />
리셋 시 적용할 시즌 번호를 지정합니다.
+ + + + +
+ 게임 규칙 자체가 아니라 Gateway가 game token을 발급할 때 적용하는 profile별 계정 + 정책입니다. +
+ +