feat(gateway): add accountable admin controls
This commit is contained in:
@@ -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();
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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]' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)"
|
||||
|
||||
+56
@@ -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();
|
||||
@@ -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,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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user