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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user