feat(gateway): add accountable admin controls

This commit is contained in:
2026-08-06 12:52:40 +00:00
parent 228fa62f67
commit 3be3e3b307
22 changed files with 1617 additions and 20 deletions
+164
View File
@@ -0,0 +1,164 @@
import { randomUUID } from 'node:crypto';
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
export type AdminAuditOutcome = 'STARTED' | 'SUCCEEDED' | 'FAILED';
export interface AdminAuditEventRecord {
id: string;
correlationId: string;
actorUserId: string;
actorUsername: string;
credentialKind: string;
capability?: string;
scope?: string;
action: string;
targetType?: string;
targetId?: string;
profileName?: string;
reason?: string;
outcome: AdminAuditOutcome;
summary: Record<string, unknown>;
errorCode?: string;
errorMessage?: string;
createdAt: string;
}
export interface AdminAuditWrite {
correlationId: string;
actorUserId: string;
actorUsername: string;
capability?: string;
scope?: string;
action: string;
targetType?: string;
targetId?: string;
profileName?: string;
reason?: string;
outcome: AdminAuditOutcome;
summary?: Record<string, unknown>;
errorCode?: string;
errorMessage?: string;
}
export interface AdminAuditStore {
append(event: AdminAuditWrite): Promise<void>;
list(input?: {
actorUserId?: string;
targetType?: string;
targetId?: string;
profileName?: string;
limit?: number;
}): Promise<AdminAuditEventRecord[]>;
}
type AuditDelegate = {
create(args: { data: Record<string, unknown> }): Promise<unknown>;
findMany(args: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
};
const asAuditDelegate = (prisma: GatewayPrismaClient): AuditDelegate | null => {
const delegate = (prisma as unknown as { adminAuditEvent?: AuditDelegate }).adminAuditEvent;
return delegate ?? null;
};
const toRecord = (row: Record<string, unknown>): AdminAuditEventRecord => ({
id: String(row.id),
correlationId: String(row.correlationId),
actorUserId: String(row.actorUserId),
actorUsername: String(row.actorUsername),
credentialKind: String(row.credentialKind),
...(typeof row.capability === 'string' ? { capability: row.capability } : {}),
...(typeof row.scope === 'string' ? { scope: row.scope } : {}),
action: String(row.action),
...(typeof row.targetType === 'string' ? { targetType: row.targetType } : {}),
...(typeof row.targetId === 'string' ? { targetId: row.targetId } : {}),
...(typeof row.profileName === 'string' ? { profileName: row.profileName } : {}),
...(typeof row.reason === 'string' ? { reason: row.reason } : {}),
outcome: row.outcome as AdminAuditOutcome,
summary:
row.summary && typeof row.summary === 'object' && !Array.isArray(row.summary)
? (row.summary as Record<string, unknown>)
: {},
...(typeof row.errorCode === 'string' ? { errorCode: row.errorCode } : {}),
...(typeof row.errorMessage === 'string' ? { errorMessage: row.errorMessage } : {}),
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
});
export const createAdminAuditStore = (prisma: GatewayPrismaClient): AdminAuditStore => ({
async append(event) {
const delegate = asAuditDelegate(prisma);
// Partial Prisma mocks in router tests intentionally omit the audit model.
if (!delegate) return;
await delegate.create({
data: {
...event,
summary: (event.summary ?? {}) as GatewayPrisma.JsonObject,
},
});
},
async list(input = {}) {
const delegate = asAuditDelegate(prisma);
if (!delegate) return [];
const rows = await delegate.findMany({
where: {
...(input.actorUserId ? { actorUserId: input.actorUserId } : {}),
...(input.targetType ? { targetType: input.targetType } : {}),
...(input.targetId ? { targetId: input.targetId } : {}),
...(input.profileName ? { profileName: input.profileName } : {}),
},
orderBy: { createdAt: 'desc' },
take: Math.min(Math.max(input.limit ?? 100, 1), 200),
});
return rows.map(toRecord);
},
});
const REDACTED_KEYS = /password|credential|token|secret|oauth|authorization|email/i;
export const sanitizeAdminAuditValue = (value: unknown, depth = 0): unknown => {
if (depth > 4) return '[DEPTH_LIMIT]';
if (value === null || typeof value === 'boolean' || typeof value === 'number') return value;
if (typeof value === 'string') return value.length > 500 ? `${value.slice(0, 500)}` : value;
if (Array.isArray(value)) return value.slice(0, 50).map((entry) => sanitizeAdminAuditValue(entry, depth + 1));
if (!value || typeof value !== 'object') return String(value);
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value).slice(0, 80)) {
result[key] = REDACTED_KEYS.test(key) ? '[REDACTED]' : sanitizeAdminAuditValue(entry, depth + 1);
}
return result;
};
export const buildAdminAuditTarget = (
rawInput: unknown
): {
targetType?: string;
targetId?: string;
profileName?: string;
reason?: string;
scope?: string;
summary: Record<string, unknown>;
} => {
const input =
rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
? (rawInput as Record<string, unknown>)
: {};
const userId = typeof input.userId === 'string' ? input.userId : undefined;
const profileName = typeof input.profileName === 'string' ? input.profileName : undefined;
const operationId = typeof input.id === 'string' ? input.id : undefined;
const reason = typeof input.reason === 'string' ? input.reason : undefined;
return {
...(userId
? { targetType: 'USER', targetId: userId }
: profileName
? { targetType: 'PROFILE', targetId: profileName }
: operationId
? { targetType: 'OPERATION', targetId: operationId }
: {}),
...(profileName ? { profileName, scope: profileName } : {}),
...(reason ? { reason } : {}),
summary: sanitizeAdminAuditValue(input) as Record<string, unknown>,
};
};
export const newAdminAuditCorrelationId = (): string => randomUUID();
+106
View File
@@ -0,0 +1,106 @@
export type AdminCapabilityRisk = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
export type AdminCapabilityScope = 'GLOBAL' | 'PROFILE';
export interface AdminCapabilityDefinition {
permission: string;
label: string;
description: string;
risk: AdminCapabilityRisk;
scope: AdminCapabilityScope;
}
export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [
{
permission: 'admin.notice.manage',
label: 'Gateway 공지 관리',
description: 'Gateway 전역 공지를 조회하고 변경합니다.',
risk: 'MEDIUM',
scope: 'GLOBAL',
},
{
permission: 'admin.users.create',
label: '로컬 계정 생성',
description: '환경에서 허용한 경우 로컬 계정을 생성합니다.',
risk: 'HIGH',
scope: 'GLOBAL',
},
{
permission: 'admin.users.manage',
label: '사용자·제재 관리',
description: '계정 복구, 제재, OAuth 유예와 예약 탈퇴를 관리합니다.',
risk: 'CRITICAL',
scope: 'GLOBAL',
},
{
permission: 'admin.audit.read',
label: '관리자 감사 조회',
description: 'Gateway 관리자 변경 이력과 실패 기록을 조회합니다.',
risk: 'HIGH',
scope: 'GLOBAL',
},
{
permission: 'admin.profiles.manage',
label: 'Profile 운영',
description: '지정 profile의 배포, 초기화와 runtime을 관리합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
},
{
permission: 'admin.reset.schedule',
label: 'Profile 초기화 예약',
description: '완료된 profile의 다음 초기화를 예약합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
},
{
permission: 'admin.resume.when-stopped',
label: '중지 Profile 재개',
description: '중지 또는 일시정지된 profile을 재개합니다.',
risk: 'HIGH',
scope: 'PROFILE',
},
{
permission: 'admin.survey.open',
label: '게임 설문 운영',
description: '지정 profile의 게임 내 설문 화면과 API를 운영합니다.',
risk: 'MEDIUM',
scope: 'PROFILE',
},
{
permission: 'admin.tournament',
label: '게임 대회 운영',
description: '지정 profile의 게임 내 토너먼트를 운영합니다.',
risk: 'HIGH',
scope: 'PROFILE',
},
{
permission: 'admin.releases.manage',
label: 'Gateway 릴리스',
description: 'Gateway control plane을 배포하거나 이전 release로 전환합니다.',
risk: 'CRITICAL',
scope: 'GLOBAL',
},
] as const;
const CAPABILITY_BY_PERMISSION = new Map(ADMIN_CAPABILITIES.map((entry) => [entry.permission, entry]));
export const getAdminCapability = (permission: string): AdminCapabilityDefinition | undefined =>
CAPABILITY_BY_PERMISSION.get(permission);
export const resolveAdminActionCapability = (path: string, rawInput?: unknown): string | undefined => {
if (path.endsWith('.users.createLocal')) return 'admin.users.create';
if (path.includes('.users.')) return 'admin.users.manage';
if (path.includes('.system.')) return 'admin.notice.manage';
if (path.includes('.releases.')) return 'admin.releases.manage';
if (path.endsWith('.profiles.requestAction') && rawInput && typeof rawInput === 'object') {
const action = (rawInput as { action?: unknown }).action;
if (action === 'RESET_SCHEDULED') return 'admin.reset.schedule';
if (action === 'RESUME') return 'admin.resume.when-stopped';
if (action === 'OPEN_SURVEY') return 'admin.survey.open';
}
if (path.includes('.operations.') || path.includes('.profiles.')) return 'admin.profiles.manage';
return undefined;
};
export const isProfileCapabilityPermission = (permission: string): boolean =>
getAdminCapability(permission)?.scope === 'PROFILE';
+262 -2
View File
@@ -10,7 +10,15 @@ import { listScenarioPreviews, resolveGitBranchCommitSha, resolveGitCommitSha }
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
import { toPublicUser } from './auth/userRepository.js';
import type { AdminAuthContext } from './adminAuth.js';
import { buildAdminAuditTarget, newAdminAuditCorrelationId, sanitizeAdminAuditValue } from './adminAudit.js';
import {
ADMIN_CAPABILITIES,
getAdminCapability,
isProfileCapabilityPermission,
resolveAdminActionCapability,
} from './adminCapabilities.js';
import type { GatewayApiContext } from './context.js';
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
@@ -41,6 +49,7 @@ const ROLE_ADMIN_USERS_CREATE = 'admin.users.create';
const ROLE_ADMIN_PROFILES = 'admin.profiles.manage';
const ROLE_ADMIN_RELEASES = 'admin.releases.manage';
const ROLE_ADMIN_NOTICE = 'admin.notice.manage';
const ROLE_ADMIN_AUDIT = 'admin.audit.read';
const ROLE_RESET_SCHEDULE = 'admin.reset.schedule';
const ROLE_RESUME_WHEN_STOPPED = 'admin.resume.when-stopped';
const ROLE_SURVEY_OPEN = 'admin.survey.open';
@@ -171,6 +180,19 @@ const assertRoleChangesAllowed = (
if (currentRoles.has(role) === nextRoles.has(role)) {
continue;
}
const parsed = splitRoleScope(role);
if (parsed.permission.startsWith(ADMIN_ROLE_PREFIX) && parsed.permission !== ADMIN_ROLE_SUPERUSER) {
const capability = getAdminCapability(parsed.permission);
if (!capability) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Unknown administrator capability: ${role}` });
}
if (capability.scope === 'GLOBAL' && parsed.scope !== undefined) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Capability does not accept a scope: ${role}` });
}
if (capability.scope === 'PROFILE' && parsed.scope === '') {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Profile scope is empty: ${role}` });
}
}
if (!canManageRole(adminAuth, role)) {
throw new TRPCError({
code: 'FORBIDDEN',
@@ -190,9 +212,36 @@ const assertPermission = (adminAuth: AdminAuthContext, permission: string, profi
});
};
const assertTargetUserManageable = (adminAuth: AdminAuthContext, target: { id: string; roles: string[] }): void => {
if (!adminAuth.isSuperuser && target.roles.some(isRootAdminRole)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only a superuser can change a root administrator account.',
});
}
};
const assertNotSelfDestructiveAction = (adminAuth: AdminAuthContext, targetUserId: string): void => {
if (adminAuth.user.id === targetUserId) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Use account self-service instead of an administrator destructive action on yourself.',
});
}
};
const canCreateLocalUser = (adminAuth: AdminAuthContext): boolean =>
hasScopedPermission(adminAuth, ROLE_ADMIN_USERS_CREATE) || hasScopedPermission(adminAuth, ROLE_ADMIN_USERS);
const canReadProfile = (adminAuth: AdminAuthContext, profileName: string): boolean => {
if (adminAuth.isSuperuser) return true;
return adminAuth.roles.some((role) => {
const parsed = splitRoleScope(role);
if (!isProfileCapabilityPermission(parsed.permission)) return false;
return parsed.scope === undefined || parsed.scope === '*' || parsed.scope === profileName;
});
};
// 로컬 계정 임의 생성은 환경 설정이 켜져 있을 때만 허용한다.
const assertLocalAccountEnabled = (ctx: GatewayApiContext): void => {
if (ctx.adminLocalAccountEnabled) {
@@ -204,7 +253,7 @@ const assertLocalAccountEnabled = (ctx: GatewayApiContext): void => {
});
};
const adminProcedure = procedure.use(async ({ ctx, next }) => {
const authenticatedAdminProcedure = procedure.use(async ({ ctx, next }) => {
const adminAuth = await resolveAdminAuth(ctx as GatewayApiContext);
return next({
ctx: {
@@ -214,6 +263,65 @@ const adminProcedure = procedure.use(async ({ ctx, next }) => {
});
});
const adminProcedure = authenticatedAdminProcedure.use(async ({ ctx, type, path, getRawInput, next }) => {
if (type !== 'mutation') {
return next();
}
const adminAuth = requireAdminAuth(ctx);
const rawInput = await getRawInput().catch(() => undefined);
const target = buildAdminAuditTarget(rawInput);
const correlationId = newAdminAuditCorrelationId();
const action = path.startsWith('admin.') ? path : `admin.${path}`;
const capability = resolveAdminActionCapability(action, rawInput);
const baseEvent = {
correlationId,
actorUserId: adminAuth.user.id,
actorUsername: adminAuth.user.username,
...(capability ? { capability } : {}),
action,
...target,
};
// STARTED 기록 실패 시 mutation을 시작하지 않는 fail-closed 경계입니다.
await (ctx as GatewayApiContext).adminAudit.append({ ...baseEvent, outcome: 'STARTED' });
try {
const result = await next();
if (!result.ok) {
await (ctx as GatewayApiContext).adminAudit
.append({
...baseEvent,
outcome: 'FAILED',
errorCode: result.error.code,
errorMessage: result.error.message.slice(0, 1000),
})
.catch(() => undefined);
return result;
}
// 업무 mutation은 이미 끝났으므로 terminal 기록 장애가 재시도/중복 mutation을
// 유발하지 않게 STARTED row를 남긴 채 원래 결과를 반환합니다.
await (ctx as GatewayApiContext).adminAudit
.append({
...baseEvent,
outcome: 'SUCCEEDED',
summary: {
request: target.summary,
result: sanitizeAdminAuditValue(result.data),
},
})
.catch(() => undefined);
return result;
} catch (error) {
await (ctx as GatewayApiContext).adminAudit
.append({
...baseEvent,
outcome: 'FAILED',
errorCode: error instanceof TRPCError ? error.code : 'INTERNAL_SERVER_ERROR',
errorMessage: error instanceof Error ? error.message.slice(0, 1000) : 'Unknown administrator error',
})
.catch(() => undefined);
throw error;
}
});
const noticeAdminProcedure = adminProcedure.use(({ ctx, next }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_NOTICE);
@@ -249,6 +357,12 @@ const releaseAdminProcedure = adminProcedure.use(({ ctx, next }) => {
return next();
});
const auditAdminProcedure = adminProcedure.use(({ ctx, next }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_AUDIT);
return next();
});
const zUserLookupInput = z
.object({
id: z.string().min(1).optional(),
@@ -402,6 +516,34 @@ const applyMetaPatch = (
};
export const adminRouter = router({
capabilities: router({
list: adminProcedure.query(({ ctx }) => {
const adminAuth = requireAdminAuth(ctx);
return ADMIN_CAPABILITIES.filter(
(entry) =>
adminAuth.isSuperuser ||
adminAuth.roles.some((role) => {
const parsed = splitRoleScope(role);
return parsed.permission === entry.permission;
})
);
}),
}),
audit: router({
list: auditAdminProcedure
.input(
z
.object({
actorUserId: z.string().min(1).optional(),
targetType: z.string().min(1).max(64).optional(),
targetId: z.string().min(1).optional(),
profileName: z.string().min(1).max(64).optional(),
limit: z.number().int().min(1).max(200).optional(),
})
.optional()
)
.query(({ ctx, input }) => (ctx as GatewayApiContext).adminAudit.list(input)),
}),
system: router({
getNotice: adminProcedure.query(async ({ ctx }) => {
const setting = await ctx.prisma.systemSetting.findUnique({
@@ -479,18 +621,89 @@ export const adminRouter = router({
oauthType: user.oauthType,
oauthId: user.oauthId,
email: user.email,
kakaoVerifiedAt: user.kakaoVerifiedAt,
kakaoGraceStartedAt: user.kakaoGraceStartedAt,
kakaoGraceUntil: user.kakaoGraceUntil,
profileIconResetAt: user.profileIconResetAt,
deleteAfter: user.deleteAfter,
createdAt: user.createdAt,
};
}),
getKakaoGracePolicies: userAdminProcedure
.input(z.object({ userId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const user = await ctx.users.findById(input.userId);
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
}
const profiles = await ctx.profiles.listProfiles();
return {
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt),
kakaoGraceStartedAt: user.kakaoGraceStartedAt,
kakaoGraceUntil: user.kakaoGraceUntil ?? null,
profiles: profiles.map((profile) => ({
profileName: profile.profileName,
...resolveLocalAccountProfilePolicy({
profile: profile.profile,
profileMeta: readMetaObject(profile.meta),
defaultGraceDays: (ctx as GatewayApiContext).localAccountGraceDays,
user,
}),
})),
};
}),
updateKakaoGrace: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
until: z.string().datetime().nullable(),
reason: z.string().trim().min(3).max(200),
})
)
.mutation(async ({ ctx, input }) => {
const user = await ctx.users.findById(input.userId);
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
}
const adminAuth = requireAdminAuth(ctx);
assertTargetUserManageable(adminAuth, user);
const until = input.until ? new Date(input.until) : null;
if (until && user.oauthType === 'KAKAO' && user.kakaoVerifiedAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'A verified Kakao account does not need a grace override.',
});
}
if (until && until.getTime() <= Date.now()) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Grace extension must end in the future.' });
}
await ctx.users.updateKakaoGraceUntil(input.userId, until);
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-kakao-grace-updated');
return { kakaoGraceUntil: until?.toISOString() ?? null };
}),
listHistory: userAdminProcedure
.input(z.object({ userId: z.string().min(1), limit: z.number().int().min(1).max(200).optional() }))
.query(({ ctx, input }) =>
(ctx as GatewayApiContext).adminAudit.list({
targetType: 'USER',
targetId: input.userId,
limit: input.limit,
})
),
resetPassword: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
newPassword: z.string().min(6).max(128).optional(),
reason: z.string().trim().min(3).max(200),
})
)
.mutation(async ({ ctx, input }) => {
const user = await ctx.users.findById(input.userId);
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
}
assertTargetUserManageable(requireAdminAuth(ctx), user);
const password = input.newPassword ?? buildAdminPassword();
await ctx.users.updatePassword(input.userId, password);
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-password-reset');
@@ -502,6 +715,7 @@ export const adminRouter = router({
userId: z.string().min(1),
roles: z.array(z.string().trim().min(1).max(128)).min(1),
mode: zUserRoleMode.optional(),
reason: z.string().trim().min(3).max(200),
})
)
.mutation(async ({ ctx, input }) => {
@@ -530,6 +744,7 @@ export const adminRouter = router({
}
}
const adminAuth = requireAdminAuth(ctx);
assertTargetUserManageable(adminAuth, user);
assertRoleChangesAllowed(adminAuth, currentRoles, roles);
const nextRoles = Array.from(roles);
await ctx.users.updateRoles(input.userId, nextRoles);
@@ -541,6 +756,7 @@ export const adminRouter = router({
z.object({
userId: z.string().min(1),
patch: zSanctionsPatch,
reason: z.string().trim().min(3).max(200),
})
)
.mutation(async ({ ctx, input }) => {
@@ -552,6 +768,7 @@ export const adminRouter = router({
});
}
const next = applySanctionsPatch(user.sanctions, input.patch);
assertTargetUserManageable(requireAdminAuth(ctx), user);
await ctx.users.updateSanctions(input.userId, next);
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-sanctions-updated');
return { sanctions: next };
@@ -562,6 +779,7 @@ export const adminRouter = router({
userId: z.string().min(1),
profile: z.string().min(1).max(64),
restriction: zServerRestriction.nullable(),
reason: z.string().trim().min(3).max(200),
})
)
.mutation(async ({ ctx, input }) => {
@@ -577,6 +795,7 @@ export const adminRouter = router({
[input.profile]: input.restriction ?? null,
},
};
assertTargetUserManageable(requireAdminAuth(ctx), user);
const next = applySanctionsPatch(user.sanctions, patch);
await ctx.users.updateSanctions(input.userId, next);
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-server-restriction');
@@ -586,6 +805,7 @@ export const adminRouter = router({
.input(
z.object({
userId: z.string().min(1),
reason: z.string().trim().min(3).max(200),
})
)
.mutation(async ({ ctx, input }) => {
@@ -596,6 +816,7 @@ export const adminRouter = router({
message: 'User not found.',
});
}
assertTargetUserManageable(requireAdminAuth(ctx), user);
const profileIconResetAt = await ctx.users.resetProfileIcon(input.userId, new Date());
if (!profileIconResetAt) {
throw new TRPCError({
@@ -613,13 +834,48 @@ export const adminRouter = router({
}
return { profileIconResetAt, flushPublished };
}),
scheduleDeletion: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
retentionDays: z.number().int().min(1).max(90).default(30),
reason: z.string().trim().min(3).max(200),
})
)
.mutation(async ({ ctx, input }) => {
const user = await ctx.users.findById(input.userId);
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
}
const adminAuth = requireAdminAuth(ctx);
assertTargetUserManageable(adminAuth, user);
assertNotSelfDestructiveAction(adminAuth, input.userId);
const deleteAfter = new Date(Date.now() + input.retentionDays * 24 * 60 * 60 * 1000);
await ctx.users.scheduleDeletion(input.userId, deleteAfter);
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-scheduled-withdrawal');
return { ok: true, deleteAfter: deleteAfter.toISOString() };
}),
forceDelete: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
confirmUsername: z.string().min(1),
reason: z.string().trim().min(3).max(200),
})
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
if (!adminAuth.isSuperuser) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Superuser permission is required.' });
}
assertNotSelfDestructiveAction(adminAuth, input.userId);
const user = await ctx.users.findById(input.userId);
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
}
if (input.confirmUsername !== user.username) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Username confirmation does not match.' });
}
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-force-withdraw');
await ctx.users.deleteUser(input.userId);
return { ok: true };
@@ -1005,7 +1261,10 @@ export const adminRouter = router({
}),
profiles: router({
list: adminProcedure.query(async ({ ctx }) => {
const profiles = await ctx.profiles.listProfiles();
const adminAuth = requireAdminAuth(ctx);
const profiles = (await ctx.profiles.listProfiles()).filter((profile) =>
canReadProfile(adminAuth, profile.profileName)
);
const profileNames = profiles.map((profile) => profile.profileName);
const [runtimeActions, activeOperations] = await Promise.all([
ctx.prisma.gatewayRuntimeAction.findMany({
@@ -1140,6 +1399,7 @@ export const adminRouter = router({
localAccountAccessGraceDays: z.number().int().min(0).max(365).nullable().optional(),
localAccountGeneralCreationGraceDays: z.number().int().min(0).max(365).nullable().optional(),
}),
reason: z.string().trim().min(3).max(200),
})
)
.mutation(async ({ ctx, input }) => {
@@ -157,6 +157,15 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
}
throw new Error('User not found.');
},
async updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
user.kakaoGraceUntil = until?.toISOString();
return;
}
}
throw new Error('User not found.');
},
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
@@ -47,6 +47,10 @@ export const resolveLocalAccountProfilePolicy = (options: {
const graceStartedAt = new Date(options.user.kakaoGraceStartedAt);
const now = options.now ?? new Date();
const accessEndsAt = new Date(graceStartedAt.getTime() + accessGraceDays * DAY_MS);
const adminGraceUntil = options.user.kakaoGraceUntil ? new Date(options.user.kakaoGraceUntil) : null;
if (adminGraceUntil && Number.isFinite(adminGraceUntil.getTime()) && adminGraceUntil > accessEndsAt) {
accessEndsAt.setTime(adminGraceUntil.getTime());
}
const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS);
const accessAllowed = kakaoVerified || bypass || now < accessEndsAt;
const canCreateGeneral = kakaoVerified || bypass || (accessAllowed && now < generalCreationEndsAt);
@@ -59,6 +59,7 @@ const mapUser = (row: {
privacyAcceptedAt: Date | null;
kakaoVerifiedAt: Date | null;
kakaoGraceStartedAt: Date;
kakaoGraceUntil: Date | null;
deleteAfter: Date | null;
createdAt: Date;
legacyData: GatewayPrisma.JsonValue;
@@ -83,6 +84,7 @@ const mapUser = (row: {
privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(),
kakaoVerifiedAt: row.kakaoVerifiedAt?.toISOString(),
kakaoGraceStartedAt: row.kakaoGraceStartedAt.toISOString(),
kakaoGraceUntil: row.kakaoGraceUntil?.toISOString(),
deleteAfter: row.deleteAfter?.toISOString(),
passwordHash: row.passwordHash,
passwordSalt: row.passwordSalt,
@@ -250,6 +252,12 @@ export const createPostgresUserRepository = (
},
});
},
async updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
data: { kakaoGraceUntil: until },
});
},
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
@@ -19,6 +19,7 @@ export interface UserRecord {
privacyAcceptedAt?: string;
kakaoVerifiedAt?: string;
kakaoGraceStartedAt: string;
kakaoGraceUntil?: string;
deleteAfter?: string;
passwordHash: string;
passwordSalt: string;
@@ -120,6 +121,7 @@ export interface UserRepository {
): Promise<UserRecord>;
updateRoles(userId: string, roles: string[]): Promise<void>;
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void>;
updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void>;
updateIconForDay(
userId: string,
+4
View File
@@ -13,6 +13,7 @@ import type { GatewayProfileStatusService } from './lobby/profileStatusService.j
import type { GatewayPrismaClient } from '@sammo-ts/infra';
import type { AdminAuthContext } from './adminAuth.js';
import type { PasswordEnvelopeService } from './auth/passwordEnvelope.js';
import { createAdminAuditStore, type AdminAuditStore } from './adminAudit.js';
export interface GatewayApiContext {
users: UserRepository;
@@ -35,6 +36,7 @@ export interface GatewayApiContext {
profileStatus: GatewayProfileStatusService;
requestHeaders: Record<string, string | string[] | undefined>;
prisma: GatewayPrismaClient;
adminAudit: AdminAuditStore;
adminAuth?: AdminAuthContext;
}
@@ -59,6 +61,7 @@ export const createGatewayApiContext = (options: {
profileStatus: GatewayProfileStatusService;
requestHeaders?: Record<string, string | string[] | undefined>;
prisma: GatewayPrismaClient;
adminAudit?: AdminAuditStore;
}): GatewayApiContext => ({
users: options.users,
sessions: options.sessions,
@@ -80,4 +83,5 @@ export const createGatewayApiContext = (options: {
profileStatus: options.profileStatus,
requestHeaders: options.requestHeaders ?? {},
prisma: options.prisma,
adminAudit: options.adminAudit ?? createAdminAuditStore(options.prisma),
});