feat(gateway): add special account access
This commit is contained in:
@@ -25,6 +25,13 @@ import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
|
||||
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
|
||||
const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES);
|
||||
const zUserRoleMode = z.enum(['set', 'grant', 'revoke']);
|
||||
const zSpecialAccountAccessKind = z.enum(['TESTER', 'RECOVERY', 'OTHER']);
|
||||
const zSpecialAccessProfile = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9_-]+(?::[a-zA-Z0-9._-]+)?$/);
|
||||
const zJoinMode = z.enum(['full', 'onlyRandom']);
|
||||
const zServerAction = z.enum([
|
||||
'RESUME',
|
||||
@@ -637,21 +644,104 @@ export const adminRouter = router({
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
|
||||
}
|
||||
const profiles = await ctx.profiles.listProfiles();
|
||||
const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id);
|
||||
return {
|
||||
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt),
|
||||
kakaoGraceStartedAt: user.kakaoGraceStartedAt,
|
||||
kakaoGraceUntil: user.kakaoGraceUntil ?? null,
|
||||
specialAccessGrants,
|
||||
profiles: profiles.map((profile) => ({
|
||||
profileName: profile.profileName,
|
||||
...resolveLocalAccountProfilePolicy({
|
||||
profile: profile.profile,
|
||||
profileName: profile.profileName,
|
||||
profileMeta: readMetaObject(profile.meta),
|
||||
defaultGraceDays: (ctx as GatewayApiContext).localAccountGraceDays,
|
||||
user,
|
||||
specialAccessGrants,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
grantSpecialAccess: userAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string().min(1),
|
||||
kind: zSpecialAccountAccessKind,
|
||||
profiles: z.array(zSpecialAccessProfile).max(20).default([]),
|
||||
allowsGeneralCreation: z.boolean().default(true),
|
||||
expiresAt: 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 expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;
|
||||
const now = new Date();
|
||||
if (expiresAt && expiresAt.getTime() <= now.getTime()) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Special access must end in the future.' });
|
||||
}
|
||||
if (input.kind === 'RECOVERY') {
|
||||
if (!expiresAt) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Recovery access must expire.' });
|
||||
}
|
||||
if (expiresAt.getTime() > now.getTime() + 90 * 24 * 60 * 60 * 1000) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Recovery access may last at most 90 days.' });
|
||||
}
|
||||
}
|
||||
const profiles = [...new Set(input.profiles.map((profile) => profile.toLowerCase()))];
|
||||
if (profiles.length > 0) {
|
||||
const knownProfiles = await ctx.profiles.listProfiles();
|
||||
const knownNames = new Set(
|
||||
knownProfiles.flatMap((profile) => [profile.profile.toLowerCase(), profile.profileName.toLowerCase()])
|
||||
);
|
||||
const unknown = profiles.find((profile) => !knownNames.has(profile));
|
||||
if (unknown) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `Unknown profile scope: ${unknown}` });
|
||||
}
|
||||
}
|
||||
const grant = await ctx.users.createSpecialAccessGrant(input.userId, {
|
||||
kind: input.kind,
|
||||
profiles,
|
||||
allowsGeneralCreation: input.allowsGeneralCreation,
|
||||
expiresAt,
|
||||
reason: input.reason,
|
||||
grantedByUserId: adminAuth.user.id,
|
||||
});
|
||||
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-special-access-granted');
|
||||
return grant;
|
||||
}),
|
||||
revokeSpecialAccess: userAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string().min(1),
|
||||
grantId: z.string().uuid(),
|
||||
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 grant = await ctx.users.revokeSpecialAccessGrant(input.userId, input.grantId, {
|
||||
revokedAt: new Date(),
|
||||
revokedByUserId: adminAuth.user.id,
|
||||
reason: input.reason,
|
||||
});
|
||||
if (!grant) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Active special access grant not found.' });
|
||||
}
|
||||
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-special-access-revoked');
|
||||
return grant;
|
||||
}),
|
||||
updateKakaoGrace: userAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
|
||||
import type { CreateUserInput, UserIconRecord, UserRecord, UserRepository } from './userRepository.js';
|
||||
import type {
|
||||
CreateUserInput,
|
||||
SpecialAccountAccessGrantRecord,
|
||||
UserIconRecord,
|
||||
UserRecord,
|
||||
UserRepository,
|
||||
} from './userRepository.js';
|
||||
|
||||
// 유저 데이터 저장소를 메모리로 대체한 임시 구현.
|
||||
export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimplePasswordHasher()): UserRepository => {
|
||||
@@ -9,6 +15,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
||||
const usersByOauthId = new Map<string, UserRecord>();
|
||||
const usersByEmail = new Map<string, UserRecord>();
|
||||
const iconsById = new Map<string, UserIconRecord>();
|
||||
const specialAccessGrantsById = new Map<string, SpecialAccountAccessGrantRecord>();
|
||||
|
||||
const nextRevision = (user: UserRecord, now: Date): string =>
|
||||
new Date(
|
||||
@@ -240,6 +247,40 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async listSpecialAccessGrants(userId: string): Promise<SpecialAccountAccessGrantRecord[]> {
|
||||
return [...specialAccessGrantsById.values()]
|
||||
.filter((grant) => grant.userId === userId)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
|
||||
},
|
||||
async createSpecialAccessGrant(userId, input): Promise<SpecialAccountAccessGrantRecord> {
|
||||
if (![...usersByName.values()].some((user) => user.id === userId)) {
|
||||
throw new Error('User not found.');
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const grant: SpecialAccountAccessGrantRecord = {
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
kind: input.kind,
|
||||
profiles: [...input.profiles],
|
||||
allowsGeneralCreation: input.allowsGeneralCreation,
|
||||
expiresAt: input.expiresAt?.toISOString(),
|
||||
reason: input.reason,
|
||||
grantedByUserId: input.grantedByUserId,
|
||||
createdAt: now,
|
||||
};
|
||||
specialAccessGrantsById.set(grant.id, grant);
|
||||
return grant;
|
||||
},
|
||||
async revokeSpecialAccessGrant(userId, grantId, input): Promise<SpecialAccountAccessGrantRecord | null> {
|
||||
const grant = specialAccessGrantsById.get(grantId);
|
||||
if (!grant || grant.userId !== userId || grant.revokedAt) {
|
||||
return null;
|
||||
}
|
||||
grant.revokedAt = input.revokedAt.toISOString();
|
||||
grant.revokedByUserId = input.revokedByUserId;
|
||||
grant.revokedReason = input.reason;
|
||||
return grant;
|
||||
},
|
||||
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id === userId) {
|
||||
@@ -389,6 +430,11 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
||||
for (const [username, user] of usersByName.entries()) {
|
||||
if (user.id === userId) {
|
||||
usersByName.delete(username);
|
||||
for (const [grantId, grant] of specialAccessGrantsById) {
|
||||
if (grant.userId === userId) {
|
||||
specialAccessGrantsById.delete(grantId);
|
||||
}
|
||||
}
|
||||
if (user.oauthType === 'KAKAO' && user.oauthId) {
|
||||
usersByOauthId.delete(`${user.oauthType}:${user.oauthId}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UserRecord } from './userRepository.js';
|
||||
import type { SpecialAccountAccessGrantRecord, UserRecord } from './userRepository.js';
|
||||
|
||||
const GENERAL_CREATION_GRACE_PROFILES = new Set(['nya', 'pya', 'hwe']);
|
||||
const ADMIN_ROLES = new Set(['superuser', 'admin', 'admin.superuser']);
|
||||
@@ -12,6 +12,12 @@ export interface LocalAccountProfilePolicy {
|
||||
graceEndsAt: string | null;
|
||||
generalCreationGraceDays: number;
|
||||
accessGraceDays: number;
|
||||
specialAccess: {
|
||||
kind: 'OPERATOR' | SpecialAccountAccessGrantRecord['kind'];
|
||||
grantId: string | null;
|
||||
expiresAt: string | null;
|
||||
allowsGeneralCreation: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const readGraceDays = (meta: Record<string, unknown>, key: string, fallback: number): number => {
|
||||
@@ -22,17 +28,67 @@ const readGraceDays = (meta: Record<string, unknown>, key: string, fallback: num
|
||||
return Math.min(Math.max(Math.floor(value), 0), 365);
|
||||
};
|
||||
|
||||
const hasAdminBypass = (user: UserRecord): boolean =>
|
||||
export const hasOperatorSpecialAccess = (user: UserRecord): boolean =>
|
||||
user.roles.some((role) => ADMIN_ROLES.has(role) || role.startsWith('admin.'));
|
||||
|
||||
export const hasActiveSpecialAccountGrant = (
|
||||
grants: readonly SpecialAccountAccessGrantRecord[],
|
||||
now: Date = new Date()
|
||||
): boolean =>
|
||||
grants.some((grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime()));
|
||||
|
||||
const appliesToProfile = (grant: SpecialAccountAccessGrantRecord, profile: string, profileName: string): boolean =>
|
||||
grant.profiles.length === 0 || grant.profiles.includes(profile) || grant.profiles.includes(profileName);
|
||||
|
||||
const resolveSpecialAccess = (options: {
|
||||
user: UserRecord;
|
||||
grants: readonly SpecialAccountAccessGrantRecord[];
|
||||
profile: string;
|
||||
profileName: string;
|
||||
now: Date;
|
||||
}): LocalAccountProfilePolicy['specialAccess'] => {
|
||||
if (hasOperatorSpecialAccess(options.user)) {
|
||||
return {
|
||||
kind: 'OPERATOR',
|
||||
grantId: null,
|
||||
expiresAt: null,
|
||||
allowsGeneralCreation: true,
|
||||
};
|
||||
}
|
||||
const active = options.grants.filter((grant) => {
|
||||
if (grant.revokedAt || !appliesToProfile(grant, options.profile, options.profileName)) {
|
||||
return false;
|
||||
}
|
||||
return !grant.expiresAt || new Date(grant.expiresAt).getTime() > options.now.getTime();
|
||||
});
|
||||
if (active.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const selected = active.find((grant) => grant.allowsGeneralCreation) ?? active[0]!;
|
||||
const expiresAt = active.some((grant) => !grant.expiresAt)
|
||||
? null
|
||||
: active
|
||||
.map((grant) => grant.expiresAt!)
|
||||
.sort((left, right) => right.localeCompare(left))[0] ?? null;
|
||||
return {
|
||||
kind: selected.kind,
|
||||
grantId: selected.id,
|
||||
expiresAt,
|
||||
allowsGeneralCreation: active.some((grant) => grant.allowsGeneralCreation),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveLocalAccountProfilePolicy = (options: {
|
||||
profile: string;
|
||||
profileName?: string;
|
||||
profileMeta?: Record<string, unknown>;
|
||||
defaultGraceDays: number;
|
||||
user: UserRecord;
|
||||
specialAccessGrants?: readonly SpecialAccountAccessGrantRecord[];
|
||||
now?: Date;
|
||||
}): LocalAccountProfilePolicy => {
|
||||
const profile = options.profile.toLowerCase();
|
||||
const profileName = (options.profileName ?? options.profile).toLowerCase();
|
||||
const meta = options.profileMeta ?? {};
|
||||
const defaultGraceDays = Math.min(Math.max(Math.floor(options.defaultGraceDays), 0), 365);
|
||||
const accessGraceDays = readGraceDays(meta, 'localAccountAccessGraceDays', defaultGraceDays);
|
||||
@@ -43,25 +99,33 @@ export const resolveLocalAccountProfilePolicy = (options: {
|
||||
generalCreationDefault
|
||||
);
|
||||
const kakaoVerified = options.user.oauthType === 'KAKAO' && Boolean(options.user.kakaoVerifiedAt);
|
||||
const bypass = hasAdminBypass(options.user);
|
||||
const graceStartedAt = new Date(options.user.kakaoGraceStartedAt);
|
||||
const now = options.now ?? new Date();
|
||||
const specialAccess = resolveSpecialAccess({
|
||||
user: options.user,
|
||||
grants: options.specialAccessGrants ?? [],
|
||||
profile,
|
||||
profileName,
|
||||
now,
|
||||
});
|
||||
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);
|
||||
const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt;
|
||||
const canCreateGeneral =
|
||||
kakaoVerified || specialAccess?.allowsGeneralCreation === true || (accessAllowed && now < generalCreationEndsAt);
|
||||
|
||||
return {
|
||||
requiresKakaoVerification: !kakaoVerified && !bypass,
|
||||
requiresKakaoVerification: !kakaoVerified && specialAccess === null,
|
||||
kakaoVerified,
|
||||
accessAllowed,
|
||||
canCreateGeneral,
|
||||
graceEndsAt: kakaoVerified || bypass ? null : accessEndsAt.toISOString(),
|
||||
graceEndsAt: kakaoVerified ? null : specialAccess ? specialAccess.expiresAt : accessEndsAt.toISOString(),
|
||||
generalCreationGraceDays,
|
||||
accessGraceDays,
|
||||
specialAccess,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
|
||||
import type {
|
||||
CreateUserInput,
|
||||
SpecialAccountAccessGrantRecord,
|
||||
UserIconRecord,
|
||||
UserOAuthInfo,
|
||||
UserRecord,
|
||||
@@ -111,6 +112,34 @@ const mapIcon = (row: {
|
||||
retiredAt: row.retiredAt?.toISOString(),
|
||||
});
|
||||
|
||||
const mapSpecialAccessGrant = (row: {
|
||||
id: string;
|
||||
userId: string;
|
||||
kind: 'TESTER' | 'RECOVERY' | 'OTHER';
|
||||
profiles: string[];
|
||||
allowsGeneralCreation: boolean;
|
||||
expiresAt: Date | null;
|
||||
reason: string;
|
||||
grantedByUserId: string;
|
||||
revokedAt: Date | null;
|
||||
revokedByUserId: string | null;
|
||||
revokedReason: string | null;
|
||||
createdAt: Date;
|
||||
}): SpecialAccountAccessGrantRecord => ({
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
kind: row.kind,
|
||||
profiles: row.profiles,
|
||||
allowsGeneralCreation: row.allowsGeneralCreation,
|
||||
expiresAt: row.expiresAt?.toISOString(),
|
||||
reason: row.reason,
|
||||
grantedByUserId: row.grantedByUserId,
|
||||
revokedAt: row.revokedAt?.toISOString(),
|
||||
revokedByUserId: row.revokedByUserId ?? undefined,
|
||||
revokedReason: row.revokedReason ?? undefined,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
export const createPostgresUserRepository = (
|
||||
prisma: GatewayPrismaClient,
|
||||
hasher: PasswordHasher = createSimplePasswordHasher()
|
||||
@@ -299,6 +328,41 @@ export const createPostgresUserRepository = (
|
||||
data: { kakaoGraceUntil: until },
|
||||
});
|
||||
},
|
||||
async listSpecialAccessGrants(userId: string): Promise<SpecialAccountAccessGrantRecord[]> {
|
||||
const rows = await prisma.specialAccountAccessGrant.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
return rows.map(mapSpecialAccessGrant);
|
||||
},
|
||||
async createSpecialAccessGrant(userId, input): Promise<SpecialAccountAccessGrantRecord> {
|
||||
const row = await prisma.specialAccountAccessGrant.create({
|
||||
data: {
|
||||
userId,
|
||||
kind: input.kind,
|
||||
profiles: input.profiles,
|
||||
allowsGeneralCreation: input.allowsGeneralCreation,
|
||||
expiresAt: input.expiresAt,
|
||||
reason: input.reason,
|
||||
grantedByUserId: input.grantedByUserId,
|
||||
},
|
||||
});
|
||||
return mapSpecialAccessGrant(row);
|
||||
},
|
||||
async revokeSpecialAccessGrant(userId, grantId, input): Promise<SpecialAccountAccessGrantRecord | null> {
|
||||
const result = await prisma.specialAccountAccessGrant.updateMany({
|
||||
where: { id: grantId, userId, revokedAt: null },
|
||||
data: {
|
||||
revokedAt: input.revokedAt,
|
||||
revokedByUserId: input.revokedByUserId,
|
||||
revokedReason: input.reason,
|
||||
},
|
||||
});
|
||||
if (result.count !== 1) {
|
||||
return null;
|
||||
}
|
||||
return mapSpecialAccessGrant(await prisma.specialAccountAccessGrant.findUniqueOrThrow({ where: { id: grantId } }));
|
||||
},
|
||||
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
|
||||
await prisma.appUser.update({
|
||||
where: { id: userId },
|
||||
|
||||
@@ -38,6 +38,23 @@ export interface UserIconRecord {
|
||||
retiredAt?: string;
|
||||
}
|
||||
|
||||
export type SpecialAccountAccessKind = 'TESTER' | 'RECOVERY' | 'OTHER';
|
||||
|
||||
export interface SpecialAccountAccessGrantRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
kind: SpecialAccountAccessKind;
|
||||
profiles: string[];
|
||||
allowsGeneralCreation: boolean;
|
||||
expiresAt?: string;
|
||||
reason: string;
|
||||
grantedByUserId: string;
|
||||
revokedAt?: string;
|
||||
revokedByUserId?: string;
|
||||
revokedReason?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type AddUserIconResult =
|
||||
{ ok: true; icon: UserIconRecord; revision: string } | { ok: false; reason: 'COOLDOWN' | 'LIMIT' | 'NOT_FOUND' };
|
||||
|
||||
@@ -134,6 +151,23 @@ export interface UserRepository {
|
||||
updateRoles(userId: string, roles: string[]): Promise<void>;
|
||||
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
|
||||
updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void>;
|
||||
listSpecialAccessGrants(userId: string): Promise<SpecialAccountAccessGrantRecord[]>;
|
||||
createSpecialAccessGrant(
|
||||
userId: string,
|
||||
input: {
|
||||
kind: SpecialAccountAccessKind;
|
||||
profiles: string[];
|
||||
allowsGeneralCreation: boolean;
|
||||
expiresAt: Date | null;
|
||||
reason: string;
|
||||
grantedByUserId: string;
|
||||
}
|
||||
): Promise<SpecialAccountAccessGrantRecord>;
|
||||
revokeSpecialAccessGrant(
|
||||
userId: string,
|
||||
grantId: string,
|
||||
input: { revokedAt: Date; revokedByUserId: string; reason: string }
|
||||
): Promise<SpecialAccountAccessGrantRecord | null>;
|
||||
updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void>;
|
||||
updateIconForDay(
|
||||
userId: string,
|
||||
|
||||
@@ -12,7 +12,11 @@ import { toPublicUser } from './auth/userRepository.js';
|
||||
import type { UserOAuthInfo, UserRecord } from './auth/userRepository.js';
|
||||
import { adminRouter } from './adminRouter.js';
|
||||
import { accountRouter } from './account/router.js';
|
||||
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
||||
import {
|
||||
hasActiveSpecialAccountGrant,
|
||||
hasOperatorSpecialAccess,
|
||||
resolveLocalAccountProfilePolicy,
|
||||
} from './auth/localAccountPolicy.js';
|
||||
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
|
||||
import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
|
||||
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
|
||||
@@ -131,14 +135,17 @@ export const appRouter = router({
|
||||
localAccountPolicy: null,
|
||||
}));
|
||||
}
|
||||
const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id);
|
||||
return Promise.all(
|
||||
profileList.map(async (profile) => {
|
||||
const record = await ctx.profiles.getProfile(profile.profileName);
|
||||
const policy = resolveLocalAccountProfilePolicy({
|
||||
profile: record?.profile ?? profile.profile,
|
||||
profileName: profile.profileName,
|
||||
profileMeta: record?.meta,
|
||||
defaultGraceDays: ctx.localAccountGraceDays,
|
||||
user,
|
||||
specialAccessGrants,
|
||||
});
|
||||
return {
|
||||
...profile,
|
||||
@@ -765,6 +772,16 @@ export const appRouter = router({
|
||||
});
|
||||
}
|
||||
if (user.oauthType === 'KAKAO') {
|
||||
const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id);
|
||||
if (hasOperatorSpecialAccess(user) || hasActiveSpecialAccountGrant(specialAccessGrants)) {
|
||||
const session = await ctx.sessions.createSession(user);
|
||||
return {
|
||||
status: 'login' as const,
|
||||
user: toPublicUser(user),
|
||||
sessionToken: session.sessionToken,
|
||||
issuedAt: session.issuedAt,
|
||||
};
|
||||
}
|
||||
const ready = await verifyStoredKakaoIdentity({
|
||||
user,
|
||||
users: ctx.users,
|
||||
@@ -881,9 +898,11 @@ export const appRouter = router({
|
||||
}
|
||||
const localAccountPolicy = resolveLocalAccountProfilePolicy({
|
||||
profile,
|
||||
profileName: input.profile,
|
||||
profileMeta: profileRecord?.meta,
|
||||
defaultGraceDays: ctx.localAccountGraceDays,
|
||||
user,
|
||||
specialAccessGrants: await ctx.users.listSpecialAccessGrants(user.id),
|
||||
});
|
||||
if (!localAccountPolicy.accessAllowed) {
|
||||
throw new TRPCError({
|
||||
@@ -932,6 +951,14 @@ export const appRouter = router({
|
||||
canCreateGeneral: localAccountPolicy.canCreateGeneral,
|
||||
requiresKakaoVerification: localAccountPolicy.requiresKakaoVerification,
|
||||
graceEndsAt: localAccountPolicy.graceEndsAt,
|
||||
...(localAccountPolicy.specialAccess
|
||||
? {
|
||||
specialAccess: {
|
||||
kind: localAccountPolicy.specialAccess.kind,
|
||||
expiresAt: localAccountPolicy.specialAccess.expiresAt,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
} as const;
|
||||
const gameToken = encryptGameSessionToken(payload, ctx.gameTokenSecret);
|
||||
|
||||
@@ -916,6 +916,70 @@ describe('Gateway administrator account controls', () => {
|
||||
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-kakao-grace-updated' });
|
||||
});
|
||||
|
||||
it('grants and revokes profile-scoped recovery access with an audit trail', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation);
|
||||
const target = await harness.users.createUser({
|
||||
username: 'recovery-target',
|
||||
password: 'secretpass',
|
||||
displayName: 'Recovery Target',
|
||||
});
|
||||
target.oauthType = 'KAKAO';
|
||||
target.oauthId = 'lost-phone-kakao-id';
|
||||
target.kakaoVerifiedAt = '2026-08-01T00:00:00.000Z';
|
||||
const expiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const grant = await harness.caller.admin.users.grantSpecialAccess({
|
||||
userId: target.id,
|
||||
kind: 'RECOVERY',
|
||||
profiles: ['che'],
|
||||
allowsGeneralCreation: true,
|
||||
expiresAt,
|
||||
reason: '휴대폰 분실 본인 확인 완료',
|
||||
});
|
||||
expect(grant).toMatchObject({
|
||||
userId: target.id,
|
||||
kind: 'RECOVERY',
|
||||
profiles: ['che'],
|
||||
allowsGeneralCreation: true,
|
||||
expiresAt,
|
||||
grantedByUserId: harness.admin.id,
|
||||
});
|
||||
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-special-access-granted' });
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.users.revokeSpecialAccess({
|
||||
userId: target.id,
|
||||
grantId: grant.id,
|
||||
reason: 'Kakao 인증 수단 복구 완료',
|
||||
})
|
||||
).resolves.toMatchObject({ id: grant.id, revokedReason: 'Kakao 인증 수단 복구 완료' });
|
||||
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-special-access-revoked' });
|
||||
expect(harness.auditEvents.filter((event) => event.outcome === 'SUCCEEDED').map((event) => event.action)).toEqual([
|
||||
'admin.users.grantSpecialAccess',
|
||||
'admin.users.revokeSpecialAccess',
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires recovery access to expire within 90 days', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation);
|
||||
const target = await harness.users.createUser({
|
||||
username: 'unsafe-recovery-target',
|
||||
password: 'secretpass',
|
||||
displayName: 'Unsafe Recovery Target',
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.users.grantSpecialAccess({
|
||||
userId: target.id,
|
||||
kind: 'RECOVERY',
|
||||
profiles: [],
|
||||
allowsGeneralCreation: true,
|
||||
expiresAt: null,
|
||||
reason: '무기한 복구 예외 거부',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
});
|
||||
|
||||
it('schedules deletion with retention and prevents administrator self-deletion', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation);
|
||||
const target = await harness.users.createUser({
|
||||
|
||||
@@ -249,6 +249,29 @@ describe('admin security over HTTP transport', () => {
|
||||
expect((await harness.users.findById(harness.target.id))?.roles).toEqual(['user']);
|
||||
});
|
||||
|
||||
it('rejects an unauthenticated special-access grant at the HTTP header boundary', async () => {
|
||||
const harness = await createHarness();
|
||||
|
||||
const rejected = await postTrpc(harness.baseUrl, 'admin.users.grantSpecialAccess', {
|
||||
userId: harness.target.id,
|
||||
kind: 'TESTER',
|
||||
profiles: ['che'],
|
||||
allowsGeneralCreation: true,
|
||||
expiresAt: null,
|
||||
reason: '미인증 특수 접근 부여 거부 테스트',
|
||||
});
|
||||
|
||||
expect(rejected.response.status).toBe(401);
|
||||
expect(rejected.body).toMatchObject({
|
||||
error: {
|
||||
data: {
|
||||
code: 'UNAUTHORIZED',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await harness.users.listSpecialAccessGrants(harness.target.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects self-escalation and set-mode removal outside a scoped administrator role', async () => {
|
||||
const harness = await createHarness();
|
||||
|
||||
|
||||
@@ -434,6 +434,139 @@ describe('gateway auth flow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('issues a CHE game token to an expired tester with an active special access grant', async () => {
|
||||
const { caller, users, sealPassword } = buildCaller({ localAccountGraceDays: 0 });
|
||||
const register = await caller.auth.registerLocal({
|
||||
username: 'special-tester',
|
||||
credential: sealPassword('tester-password'),
|
||||
displayName: '특수테스터',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
const user = await users.findByUsername('special-tester');
|
||||
expect(user).not.toBeNull();
|
||||
if (!user) throw new Error('Expected local tester.');
|
||||
await users.createSpecialAccessGrant(user.id, {
|
||||
kind: 'TESTER',
|
||||
profiles: ['che'],
|
||||
allowsGeneralCreation: true,
|
||||
expiresAt: null,
|
||||
reason: 'CHE 회귀 검증',
|
||||
grantedByUserId: 'admin-id',
|
||||
});
|
||||
|
||||
const issued = await caller.auth.issueGameSession({
|
||||
sessionToken: register.sessionToken,
|
||||
profile: 'che:default',
|
||||
});
|
||||
const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');
|
||||
|
||||
expect(payload?.identity).toMatchObject({
|
||||
kakaoVerified: false,
|
||||
canCreateGeneral: true,
|
||||
requiresKakaoVerification: false,
|
||||
specialAccess: { kind: 'TESTER', expiresAt: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('lets a Kakao-linked recovery account log in with its password while the grant is active', async () => {
|
||||
const { caller, users, sealPassword } = buildCaller();
|
||||
await caller.auth.registerLocal({
|
||||
username: 'lost-phone-user',
|
||||
credential: sealPassword('recovery-password'),
|
||||
displayName: '분실복구유저',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
const user = await users.findByUsername('lost-phone-user');
|
||||
expect(user).not.toBeNull();
|
||||
if (!user) throw new Error('Expected recovery user.');
|
||||
user.oauthType = 'KAKAO';
|
||||
user.oauthId = 'lost-phone-kakao-id';
|
||||
user.kakaoVerifiedAt = '2026-08-01T00:00:00.000Z';
|
||||
await users.createSpecialAccessGrant(user.id, {
|
||||
kind: 'RECOVERY',
|
||||
profiles: ['che'],
|
||||
allowsGeneralCreation: true,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
reason: '휴대폰 분실 본인 확인 완료',
|
||||
grantedByUserId: 'admin-id',
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.auth.login({
|
||||
username: 'lost-phone-user',
|
||||
credential: sealPassword('recovery-password'),
|
||||
})
|
||||
).resolves.toMatchObject({ status: 'login', user: { username: 'lost-phone-user' } });
|
||||
});
|
||||
|
||||
it('lets a Kakao-linked operator log in with its password without a grant', async () => {
|
||||
const { caller, users, sealPassword } = buildCaller();
|
||||
await caller.auth.registerLocal({
|
||||
username: 'oauth-free-operator',
|
||||
credential: sealPassword('operator-password'),
|
||||
displayName: '복구운영자',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
const user = await users.findByUsername('oauth-free-operator');
|
||||
expect(user).not.toBeNull();
|
||||
if (!user) throw new Error('Expected operator user.');
|
||||
user.oauthType = 'KAKAO';
|
||||
user.oauthId = 'operator-kakao-id';
|
||||
user.kakaoVerifiedAt = '2026-08-01T00:00:00.000Z';
|
||||
await users.updateRoles(user.id, ['user', 'admin.users.manage']);
|
||||
|
||||
await expect(
|
||||
caller.auth.login({
|
||||
username: 'oauth-free-operator',
|
||||
credential: sealPassword('operator-password'),
|
||||
})
|
||||
).resolves.toMatchObject({ status: 'login', user: { username: 'oauth-free-operator' } });
|
||||
});
|
||||
|
||||
it('keeps an active server sanction authoritative over special access', async () => {
|
||||
const { caller, users, sealPassword } = buildCaller({ localAccountGraceDays: 0 });
|
||||
const register = await caller.auth.registerLocal({
|
||||
username: 'sanctioned-special-tester',
|
||||
credential: sealPassword('tester-password'),
|
||||
displayName: '제재특수테스터',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
const user = await users.findByUsername('sanctioned-special-tester');
|
||||
expect(user).not.toBeNull();
|
||||
if (!user) throw new Error('Expected sanctioned local tester.');
|
||||
await users.createSpecialAccessGrant(user.id, {
|
||||
kind: 'TESTER',
|
||||
profiles: ['che'],
|
||||
allowsGeneralCreation: true,
|
||||
expiresAt: null,
|
||||
reason: 'CHE 회귀 검증',
|
||||
grantedByUserId: 'admin-id',
|
||||
});
|
||||
await users.updateSanctions(user.id, {
|
||||
serverRestrictions: {
|
||||
che: {
|
||||
blockedFeatures: ['login'],
|
||||
until: '2099-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.auth.issueGameSession({
|
||||
sessionToken: register.sessionToken,
|
||||
profile: 'che:default',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('links Kakao to the logged-in local account instead of creating a second user', async () => {
|
||||
const { caller, users, sealPassword, setSessionHeader, sentTalkMessages } = buildCaller();
|
||||
const register = await caller.auth.registerLocal({
|
||||
|
||||
@@ -107,4 +107,90 @@ describe('local account profile policy', () => {
|
||||
generalCreationGraceDays: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats every administrator role as permanent operator access', () => {
|
||||
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
|
||||
user.roles = ['user', 'admin.users.manage'];
|
||||
const policy = resolveLocalAccountProfilePolicy({
|
||||
profile: 'che',
|
||||
profileName: 'che:2',
|
||||
defaultGraceDays: 0,
|
||||
user,
|
||||
now: new Date('2026-08-08T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(policy).toMatchObject({
|
||||
accessAllowed: true,
|
||||
canCreateGeneral: true,
|
||||
requiresKakaoVerification: false,
|
||||
specialAccess: {
|
||||
kind: 'OPERATOR',
|
||||
grantId: null,
|
||||
expiresAt: null,
|
||||
allowsGeneralCreation: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('applies a profile-scoped tester grant to CHE including general creation', () => {
|
||||
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
|
||||
const policy = resolveLocalAccountProfilePolicy({
|
||||
profile: 'che',
|
||||
profileName: 'che:2',
|
||||
defaultGraceDays: 0,
|
||||
user,
|
||||
specialAccessGrants: [
|
||||
{
|
||||
id: 'grant-1',
|
||||
userId: user.id,
|
||||
kind: 'TESTER',
|
||||
profiles: ['che'],
|
||||
allowsGeneralCreation: true,
|
||||
reason: '고정 시나리오 검증',
|
||||
grantedByUserId: 'admin-id',
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
now: new Date('2026-08-08T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(policy).toMatchObject({
|
||||
accessAllowed: true,
|
||||
canCreateGeneral: true,
|
||||
requiresKakaoVerification: false,
|
||||
specialAccess: { kind: 'TESTER', grantId: 'grant-1', allowsGeneralCreation: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores expired, revoked, and different-profile grants', () => {
|
||||
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
|
||||
const baseGrant = {
|
||||
userId: user.id,
|
||||
kind: 'RECOVERY' as const,
|
||||
profiles: ['che'],
|
||||
allowsGeneralCreation: true,
|
||||
reason: '단말 분실 복구',
|
||||
grantedByUserId: 'admin-id',
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
};
|
||||
const policy = resolveLocalAccountProfilePolicy({
|
||||
profile: 'che',
|
||||
profileName: 'che:2',
|
||||
defaultGraceDays: 0,
|
||||
user,
|
||||
specialAccessGrants: [
|
||||
{ ...baseGrant, id: 'expired', expiresAt: '2026-08-07T00:00:00.000Z' },
|
||||
{ ...baseGrant, id: 'revoked', revokedAt: '2026-08-07T00:00:00.000Z' },
|
||||
{ ...baseGrant, id: 'other-profile', profiles: ['hwe'], expiresAt: '2026-09-01T00:00:00.000Z' },
|
||||
],
|
||||
now: new Date('2026-08-08T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(policy).toMatchObject({
|
||||
accessAllowed: false,
|
||||
canCreateGeneral: false,
|
||||
requiresKakaoVerification: true,
|
||||
specialAccess: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('readReleaseManifest', () => {
|
||||
const workspaceRoot = path.resolve(import.meta.dirname, '../../..');
|
||||
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
gatewaySchemaHead: '20260808000000_add_kakao_talk_verification',
|
||||
gatewaySchemaHead: '20260808001000_add_special_account_access_grants',
|
||||
gameSchemaHead: '20260803000000_add_logical_game_clock',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
|
||||
|
||||
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const userId = '14a4d550-e92c-4aec-81e1-e6235dc17ded';
|
||||
const adminId = 'b6b327d8-e95e-4858-9b66-4fd22a286145';
|
||||
|
||||
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('special account access PostgreSQL boundary', () => {
|
||||
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();
|
||||
await db.appUser.deleteMany({ where: { id: userId } });
|
||||
await db.appUser.create({
|
||||
data: {
|
||||
id: userId,
|
||||
loginId: 'special-access-integration',
|
||||
displayName: '특수 접근 통합',
|
||||
passwordHash: 'not-used',
|
||||
passwordSalt: 'not-used',
|
||||
roles: ['user'],
|
||||
sanctions: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.appUser.deleteMany({ where: { id: userId } });
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('persists profile scope and preserves revocation provenance', async () => {
|
||||
const users = createPostgresUserRepository(db);
|
||||
const grant = await users.createSpecialAccessGrant(userId, {
|
||||
kind: 'RECOVERY',
|
||||
profiles: ['che'],
|
||||
allowsGeneralCreation: true,
|
||||
expiresAt: new Date('2026-09-01T00:00:00.000Z'),
|
||||
reason: '분실 단말 복구 기간',
|
||||
grantedByUserId: adminId,
|
||||
});
|
||||
|
||||
await expect(users.listSpecialAccessGrants(userId)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: grant.id,
|
||||
kind: 'RECOVERY',
|
||||
profiles: ['che'],
|
||||
allowsGeneralCreation: true,
|
||||
expiresAt: '2026-09-01T00:00:00.000Z',
|
||||
grantedByUserId: adminId,
|
||||
}),
|
||||
]);
|
||||
|
||||
const revoked = await users.revokeSpecialAccessGrant(userId, grant.id, {
|
||||
revokedAt: new Date('2026-08-20T00:00:00.000Z'),
|
||||
revokedByUserId: adminId,
|
||||
reason: 'Kakao 인증 복구 완료',
|
||||
});
|
||||
expect(revoked).toMatchObject({
|
||||
id: grant.id,
|
||||
revokedAt: '2026-08-20T00:00:00.000Z',
|
||||
revokedByUserId: adminId,
|
||||
revokedReason: 'Kakao 인증 복구 완료',
|
||||
});
|
||||
await expect(
|
||||
users.revokeSpecialAccessGrant(userId, grant.id, {
|
||||
revokedAt: new Date('2026-08-21T00:00:00.000Z'),
|
||||
revokedByUserId: adminId,
|
||||
reason: '중복 해제',
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user