feat(gateway): add accountable admin controls

This commit is contained in:
2026-08-06 12:52:40 +00:00
parent 228fa62f67
commit 3be3e3b307
22 changed files with 1617 additions and 20 deletions
+164
View File
@@ -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<string, unknown>;
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<string, unknown>;
errorCode?: string;
errorMessage?: string;
}
export interface AdminAuditStore {
append(event: AdminAuditWrite): Promise<void>;
list(input?: {
actorUserId?: string;
targetType?: string;
targetId?: string;
profileName?: string;
limit?: number;
}): Promise<AdminAuditEventRecord[]>;
}
type AuditDelegate = {
create(args: { data: Record<string, unknown> }): Promise<unknown>;
findMany(args: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
};
const asAuditDelegate = (prisma: GatewayPrismaClient): AuditDelegate | null => {
const delegate = (prisma as unknown as { adminAuditEvent?: AuditDelegate }).adminAuditEvent;
return delegate ?? null;
};
const toRecord = (row: Record<string, unknown>): 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<string, unknown>)
: {},
...(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<string, unknown> = {};
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<string, unknown>;
} => {
const input =
rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
? (rawInput as Record<string, unknown>)
: {};
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<string, unknown>,
};
};
export const newAdminAuditCorrelationId = (): string => randomUUID();
+106
View File
@@ -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';
+262 -2
View File
@@ -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 }) => {
@@ -157,6 +157,15 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
}
throw new Error('User not found.');
},
async updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void> {
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<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
@@ -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);
@@ -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<void> {
await prisma.appUser.update({
where: { id: userId },
data: { kakaoGraceUntil: until },
});
},
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
@@ -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<UserRecord>;
updateRoles(userId: string, roles: string[]): Promise<void>;
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void>;
updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void>;
updateIconForDay(
userId: string,
+4
View File
@@ -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<string, string | string[] | undefined>;
prisma: GatewayPrismaClient;
adminAudit: AdminAuditStore;
adminAuth?: AdminAuthContext;
}
@@ -59,6 +61,7 @@ export const createGatewayApiContext = (options: {
profileStatus: GatewayProfileStatusService;
requestHeaders?: Record<string, string | string[] | undefined>;
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),
});
@@ -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<void>) | 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/);
});
});
+34
View File
@@ -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]' },
});
});
});
+168 -1
View File
@@ -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<string, unknown>[] = [];
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' });
});
});
@@ -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
);
@@ -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,
});
});
});
+1 -1
View File
@@ -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',
});
});
@@ -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 });
});
@@ -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([
{
@@ -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({
@@ -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',
+478 -15
View File
@@ -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<string, unknown>;
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<AdminCapability[]> };
};
audit: {
list: { query: (input?: { limit?: number }) => Promise<AdminAuditEvent[]> };
};
system: {
getNotice: {
query: () => Promise<{ notice: string }>;
@@ -162,20 +206,38 @@ type AdminClient = {
lookup: {
query: (input: { id?: string; username?: string; email?: string }) => Promise<AdminUser | null>;
};
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<AdminAuditEvent[]>;
};
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<AdminProfile | null>;
};
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<AdminCapability[]>([]);
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<KakaoGracePolicy[]>([]);
const userHistory = ref<AdminAuditEvent[]>([]);
const globalAuditHistory = ref<AdminAuditEvent[]>([]);
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(() => {
</section>
<div class="grid lg:grid-cols-2 gap-8">
<section class="space-y-6">
<section class="min-w-0 space-y-6">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h3 class="text-lg font-semibold">유저 관리</h3>
<form class="space-y-3" @submit.prevent="lookupUser">
@@ -1192,6 +1419,16 @@ onMounted(() => {
<div class="text-xs text-zinc-500">
OAuth: {{ userResult.oauthType }} {{ userResult.email ?? '' }}
</div>
<div class="text-xs text-zinc-500">
Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작:
{{ new Date(userResult.kakaoGraceStartedAt).toLocaleString('ko-KR') }}
</div>
<div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300">
관리자 유예: {{ new Date(userResult.kakaoGraceUntil).toLocaleString('ko-KR') }}까지
</div>
<div v-if="userResult.deleteAfter" class="text-xs text-red-300">
탈퇴 예약: {{ new Date(userResult.deleteAfter).toLocaleString('ko-KR') }}
</div>
<div class="text-xs text-zinc-500">가입일: {{ userResult.createdAt }}</div>
<div class="text-xs text-zinc-400 mt-2">제재 상태</div>
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap"
@@ -1200,6 +1437,18 @@ onMounted(() => {
</div>
</div>
<div class="bg-zinc-900 border border-amber-800/70 rounded-lg p-5 space-y-3">
<h4 class="text-base font-semibold">민감 조치 공통 사유</h4>
<input
v-model="userActionReason"
type="text"
maxlength="200"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="권한·제재·복구·탈퇴 조치 사유 (필수)"
/>
<div class="text-xs text-zinc-500">사유와 정화된 입력은 관리자 감사 원장에 기록됩니다.</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-base font-semibold">로컬 계정 생성</h4>
@@ -1266,6 +1515,38 @@ onMounted(() => {
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">특수 권한 부여</h4>
<div class="grid gap-2 md:grid-cols-[1fr_1fr_auto]">
<select
v-model="selectedCapability"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
>
<option
v-for="capability in capabilities"
:key="capability.permission"
:value="capability.permission"
>
{{ capability.label }} · {{ capability.risk }}
</option>
</select>
<input
v-model="capabilityProfile"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="Profile 범위 (예: che:default)"
:disabled="!hasUser"
/>
<button
class="bg-zinc-700 hover:bg-zinc-600 px-3 py-2 rounded text-sm"
:disabled="!hasUser"
@click="applyCapabilitySelection"
>
선택 반영
</button>
</div>
<div v-if="selectedCapability" class="text-xs text-zinc-500">
{{ capabilities.find((item) => item.permission === selectedCapability)?.description }}
</div>
<div class="flex flex-col md:flex-row gap-2">
<select
v-model="rolesMode"
@@ -1294,6 +1575,68 @@ onMounted(() => {
<div class="text-xs text-zinc-500">{{ rolesStatus }}</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">Kakao 인증 유예</h4>
<div class="text-xs text-zinc-500">
기본·서버별 유예가 끝난 사용자를 예외적으로 허용할 사용합니다.
</div>
<div class="flex flex-col md:flex-row gap-2">
<input
v-model="kakaoGraceUntil"
type="datetime-local"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
/>
<button
class="bg-yellow-600 hover:bg-yellow-500 text-black px-4 py-2 rounded"
:disabled="!hasUser"
@click="updateKakaoGrace(false)"
>
유예 연장
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 px-4 py-2 rounded"
:disabled="!hasUser"
@click="updateKakaoGrace(true)"
>
개별 유예 해제
</button>
</div>
<div class="text-xs text-zinc-500">{{ kakaoGraceStatus }}</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[620px] text-xs">
<thead class="text-zinc-500">
<tr>
<th class="p-2 text-left">Profile</th>
<th>접근</th>
<th>장수 생성</th>
<th>기본 접근 유예</th>
<th>종료</th>
</tr>
</thead>
<tbody>
<tr
v-for="policy in kakaoPolicies"
:key="policy.profileName"
class="border-t border-zinc-800"
>
<td class="p-2">{{ policy.profileName }}</td>
<td class="text-center">{{ policy.accessAllowed ? '허용' : '차단' }}</td>
<td class="text-center">{{ policy.canCreateGeneral ? '허용' : '차단' }}</td>
<td class="text-center">{{ policy.accessGraceDays }}</td>
<td class="text-center">
{{
policy.graceEndsAt
? new Date(policy.graceEndsAt).toLocaleString('ko-KR')
: '-'
}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">유저 차단</h4>
<div class="flex flex-col gap-2">
@@ -1400,19 +1743,109 @@ onMounted(() => {
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold text-red-400">강제 탈퇴</h4>
<h4 class="text-base font-semibold text-red-400">관리자 탈퇴 예약</h4>
<input
v-model.number="deletionRetentionDays"
type="number"
min="1"
max="90"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
aria-label="탈퇴 보존 일수"
:disabled="!hasUser"
/>
<button
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="forceDeleteUser"
@click="scheduleDeleteUser"
>
강제 탈퇴 처리
보존 기간 탈퇴 예약
</button>
<div class="text-xs text-zinc-500">{{ forceDeleteStatus }}</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">사용자 관리자 조치 이력</h4>
<div v-if="!userHistory.length" class="text-xs text-zinc-500">기록이 없습니다.</div>
<div v-else class="max-h-80 overflow-auto space-y-2">
<div
v-for="event in userHistory"
:key="event.id"
class="border border-zinc-800 rounded p-3 text-xs"
>
<div class="flex justify-between gap-3">
<span
:class="
event.outcome === 'FAILED'
? 'text-red-300'
: event.outcome === 'SUCCEEDED'
? 'text-emerald-300'
: 'text-amber-300'
"
>
{{ event.outcome }} · {{ event.action }}
</span>
<span class="text-zinc-500">{{
new Date(event.createdAt).toLocaleString('ko-KR')
}}</span>
</div>
<div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }}
</div>
<div v-if="event.errorMessage" class="text-red-300">{{ event.errorMessage }}</div>
<pre class="mt-2 overflow-auto whitespace-pre-wrap text-[11px] text-zinc-500">{{
JSON.stringify(event.summary, null, 2)
}}</pre>
</div>
</div>
</div>
</section>
<section class="space-y-6">
<section class="min-w-0 space-y-6">
<div
v-if="capabilities.some((entry) => entry.permission === 'admin.audit.read')"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<div class="flex items-center justify-between gap-3">
<h3 class="text-lg font-semibold">전체 관리자 감사 원장</h3>
<button
class="rounded bg-zinc-700 px-3 py-2 text-xs hover:bg-zinc-600"
@click="loadGlobalAudit"
>
새로고침
</button>
</div>
<div v-if="globalAuditStatus" class="text-xs text-red-400">{{ globalAuditStatus }}</div>
<div v-if="!globalAuditHistory.length" class="text-xs text-zinc-500">기록이 없습니다.</div>
<div v-else class="max-h-96 space-y-2 overflow-auto">
<div
v-for="event in globalAuditHistory"
:key="event.id"
class="rounded border border-zinc-800 p-3 text-xs"
>
<div class="flex justify-between gap-3">
<span
:class="
event.outcome === 'FAILED'
? 'text-red-300'
: event.outcome === 'SUCCEEDED'
? 'text-emerald-300'
: 'text-amber-300'
"
>
{{ event.outcome }} · {{ event.action }}
</span>
<span class="text-zinc-500">{{
new Date(event.createdAt).toLocaleString('ko-KR')
}}</span>
</div>
<div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.targetType ?? '-' }}
{{ event.targetId ?? event.profileName ?? '' }} · {{ event.reason ?? '사유 없음' }}
</div>
</div>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">서버 공지</h3>
@@ -1510,6 +1943,36 @@ onMounted(() => {
placeholder="예: 12"
/>
<div class="text-xs text-zinc-500">리셋 적용할 시즌 번호를 지정합니다.</div>
<label class="text-xs text-zinc-400">Kakao 미인증 접근 유예일</label>
<input
v-model="profileEdits[profile.profileName].localAccountAccessGraceDays"
type="number"
min="0"
max="365"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="비우면 Gateway 기본값"
/>
<label class="text-xs text-zinc-400">Kakao 미인증 장수 생성 유예일</label>
<input
v-model="profileEdits[profile.profileName].localAccountGeneralCreationGraceDays"
type="number"
min="0"
max="365"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="비우면 서버 기본값"
/>
<div class="text-xs text-zinc-500">
게임 규칙 자체가 아니라 Gateway가 game token을 발급할 적용하는 profile별 계정
정책입니다.
</div>
<label class="text-xs text-zinc-400">메타 변경 사유</label>
<input
v-model="profileEdits[profile.profileName].reason"
type="text"
maxlength="200"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="변경 사유 (필수)"
/>
<button
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded"
@click="updateProfileMeta(profile.profileName)"
@@ -0,0 +1,56 @@
DO $$
BEGIN
CREATE TYPE "AdminAuditOutcome" AS ENUM ('STARTED', 'SUCCEEDED', 'FAILED');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
ALTER TABLE "app_user"
ADD COLUMN IF NOT EXISTS "kakao_grace_until" TIMESTAMP(3);
CREATE TABLE IF NOT EXISTS "admin_audit_event" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"correlation_id" TEXT NOT NULL,
"actor_user_id" TEXT NOT NULL,
"actor_username" TEXT NOT NULL,
"credential_kind" TEXT NOT NULL DEFAULT 'SESSION',
"capability" TEXT,
"scope" TEXT,
"action" TEXT NOT NULL,
"target_type" TEXT,
"target_id" TEXT,
"profile_name" TEXT,
"reason" TEXT,
"outcome" "AdminAuditOutcome" NOT NULL,
"summary" JSONB NOT NULL DEFAULT '{}'::jsonb,
"error_code" TEXT,
"error_message" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "admin_audit_event_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "admin_audit_event_correlation_id_created_at_idx"
ON "admin_audit_event"("correlation_id", "created_at");
CREATE INDEX IF NOT EXISTS "admin_audit_event_actor_user_id_created_at_idx"
ON "admin_audit_event"("actor_user_id", "created_at");
CREATE INDEX IF NOT EXISTS "admin_audit_event_target_type_target_id_created_at_idx"
ON "admin_audit_event"("target_type", "target_id", "created_at");
CREATE INDEX IF NOT EXISTS "admin_audit_event_profile_name_created_at_idx"
ON "admin_audit_event"("profile_name", "created_at");
CREATE INDEX IF NOT EXISTS "admin_audit_event_action_created_at_idx"
ON "admin_audit_event"("action", "created_at");
COMMENT ON TABLE "admin_audit_event" IS
'Append-only administrator action ledger. Application code must never update or delete rows.';
CREATE OR REPLACE FUNCTION reject_admin_audit_event_mutation()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'admin_audit_event is append-only';
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS "admin_audit_event_append_only" ON "admin_audit_event";
CREATE TRIGGER "admin_audit_event_append_only"
BEFORE UPDATE OR DELETE ON "admin_audit_event"
FOR EACH ROW EXECUTE FUNCTION reject_admin_audit_event_mutation();
+34
View File
@@ -59,6 +59,12 @@ enum GatewayRuntimeActionStatus {
IGNORED
}
enum AdminAuditOutcome {
STARTED
SUCCEEDED
FAILED
}
enum GatewaySourceMode {
BRANCH
COMMIT
@@ -87,6 +93,7 @@ model AppUser {
privacyAcceptedAt DateTime? @map("privacy_accepted_at")
kakaoVerifiedAt DateTime? @map("kakao_verified_at")
kakaoGraceStartedAt DateTime @default(now()) @map("kakao_grace_started_at")
kakaoGraceUntil DateTime? @map("kakao_grace_until")
deleteAfter DateTime? @map("delete_after")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -97,6 +104,33 @@ model AppUser {
@@map("app_user")
}
model AdminAuditEvent {
id String @id @default(uuid())
correlationId String @map("correlation_id")
actorUserId String @map("actor_user_id")
actorUsername String @map("actor_username")
credentialKind String @default("SESSION") @map("credential_kind")
capability String?
scope String?
action String
targetType String? @map("target_type")
targetId String? @map("target_id")
profileName String? @map("profile_name")
reason String?
outcome AdminAuditOutcome
summary Json @default(dbgenerated("'{}'::jsonb"))
errorCode String? @map("error_code")
errorMessage String? @map("error_message")
createdAt DateTime @default(now()) @map("created_at")
@@index([correlationId, createdAt])
@@index([actorUserId, createdAt])
@@index([targetType, targetId, createdAt])
@@index([profileName, createdAt])
@@index([action, createdAt])
@@map("admin_audit_event")
}
model UserIcon {
id String @id @default(uuid())
userId String @map("user_id")
+1 -1
View File
@@ -1,7 +1,7 @@
{
"formatVersion": 1,
"controllerProtocol": 1,
"gatewaySchemaHead": "20260801000000_add_user_icon_library",
"gatewaySchemaHead": "20260806000000_add_admin_audit_and_kakao_grace",
"gameSchemaHead": "20260803000000_add_logical_game_clock",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
}