feat(gateway): add special account access
This commit is contained in:
@@ -50,6 +50,16 @@ Kakao가 `already registered`를 반환했지만 보존 이메일 계정도 없
|
||||
확인 뒤 신규 가입 form을 엽니다. 이미 로컬에 연결된 stable ID의 변경 이메일이
|
||||
다른 계정에 있으면 기존처럼 충돌을 거부합니다.
|
||||
|
||||
Kakao 인증을 사용할 수 없는 운영·검증·복구 계정은 Gateway의 특수 접근 자격으로
|
||||
게임 서버에 들어갈 수 있습니다. `superuser`, `admin`, `admin.*` role은 운영자
|
||||
자격으로 모든 profile과 장수 생성에 자동 허용됩니다. 그 밖의 계정은 관리자
|
||||
콘솔에서 `TESTER`, `RECOVERY`, `OTHER` grant를 profile 범위, 만료, 장수 생성
|
||||
허용 여부와 사유를 지정해 부여합니다. `RECOVERY`는 최대 90일의 만료가 필수이며,
|
||||
부여·해제는 감사 원장과 별도 DB 이력에 모두 남습니다. 계정 제재는 이 자격보다
|
||||
먼저 검사됩니다. 기존 Kakao 연결 계정이 인증 수단을 잃은 경우에도 비밀번호
|
||||
인증은 유지하면서 유효한 특수 자격 기간에는 Kakao 공급자 호출 없이 로그인할 수
|
||||
있습니다.
|
||||
|
||||
각 game profile은 별도 PostgreSQL schema를 사용합니다. `game-api`는 인증된
|
||||
요청을 검증하고 직접 처리할 mutation 또는 daemon 입력을
|
||||
`InputEvent`에 기록합니다. `game-engine`은 DB lease와 fencing token을 확보한
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ const installFixture = async (page: Page) => {
|
||||
const mutations: Array<{ operation: string; body: unknown }> = [];
|
||||
let deleteAfter: string | null = null;
|
||||
let graceUntil: string | null = null;
|
||||
let specialGrants: Array<Record<string, unknown>> = [];
|
||||
const auditHistory = [
|
||||
{
|
||||
id: 'audit-1',
|
||||
@@ -89,6 +90,7 @@ const installFixture = async (page: Page) => {
|
||||
kakaoVerified: false,
|
||||
kakaoGraceStartedAt: '2026-07-20T00:00:00.000Z',
|
||||
kakaoGraceUntil: graceUntil,
|
||||
specialAccessGrants: specialGrants,
|
||||
profiles: [
|
||||
{
|
||||
profileName: 'che:default',
|
||||
@@ -99,6 +101,14 @@ const installFixture = async (page: Page) => {
|
||||
graceEndsAt: graceUntil ?? '2026-08-10T00:00:00.000Z',
|
||||
generalCreationGraceDays: 0,
|
||||
accessGraceDays: 7,
|
||||
specialAccess: specialGrants.length
|
||||
? {
|
||||
kind: 'RECOVERY',
|
||||
grantId: '11111111-1111-4111-8111-111111111111',
|
||||
expiresAt: '2026-08-20T00:00:00.000Z',
|
||||
allowsGeneralCreation: true,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -114,6 +124,28 @@ const installFixture = async (page: Page) => {
|
||||
});
|
||||
return response({ kakaoGraceUntil: graceUntil });
|
||||
}
|
||||
if (operation === 'admin.users.grantSpecialAccess') {
|
||||
specialGrants = [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
userId: 'target-user',
|
||||
kind: 'RECOVERY',
|
||||
profiles: ['che'],
|
||||
allowsGeneralCreation: true,
|
||||
expiresAt: '2026-08-20T00:00:00.000Z',
|
||||
reason: '휴대폰 분실 임시 복구',
|
||||
grantedByUserId: 'admin-user',
|
||||
createdAt: '2026-08-08T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
auditHistory.unshift({
|
||||
...auditHistory[0],
|
||||
id: 'audit-special',
|
||||
action: 'admin.users.grantSpecialAccess',
|
||||
reason: '휴대폰 분실 임시 복구',
|
||||
});
|
||||
return response(specialGrants[0]);
|
||||
}
|
||||
if (operation === 'admin.users.scheduleDeletion') {
|
||||
deleteAfter = '2026-09-05T00:00:00.000Z';
|
||||
auditHistory.unshift({
|
||||
@@ -150,7 +182,17 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
|
||||
.not.toBe(baseDeleteColor);
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-hover.png'), fullPage: true });
|
||||
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중');
|
||||
await page.locator('input[type="datetime-local"]').nth(0).fill('2026-08-20T00:00');
|
||||
await page.getByLabel('특수 접근 만료 시각').fill('2026-08-20T00:00');
|
||||
await page.getByPlaceholder('che 또는 che:2 (쉼표 구분, 비우면 전체)').fill('che');
|
||||
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('휴대폰 분실 임시 복구');
|
||||
await page.getByRole('button', { name: '특수 접근 부여', exact: true }).click();
|
||||
await expect(page.getByText('특수 접근 자격을 부여했습니다.')).toBeVisible();
|
||||
await expect(page.getByText(/RECOVERY · che/)).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-admin-special-access-granted.png'), fullPage: true });
|
||||
|
||||
const gracePanel = page.getByRole('heading', { name: 'Kakao 인증 유예' }).locator('..');
|
||||
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중');
|
||||
await gracePanel.locator('input[type="datetime-local"]').fill('2026-08-20T00:00');
|
||||
await page.getByRole('button', { name: '유예 연장', exact: true }).click();
|
||||
await expect(page.getByText('OAuth 유예 연장 완료')).toBeVisible();
|
||||
await expect(page.getByText('SUCCEEDED · admin.users.updateKakaoGrace').first()).toBeVisible();
|
||||
@@ -160,6 +202,7 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
|
||||
await deletionButton.click();
|
||||
await expect(page.getByText(/탈퇴 예약 완료/)).toBeVisible();
|
||||
expect(mutations.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true);
|
||||
expect(mutations.some(({ operation }) => operation === 'admin.users.grantSpecialAccess')).toBe(true);
|
||||
expect(mutations.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true);
|
||||
|
||||
await page.getByRole('link', { name: '감사 로그' }).click();
|
||||
|
||||
@@ -119,6 +119,26 @@ type KakaoGracePolicy = {
|
||||
graceEndsAt: string | null;
|
||||
generalCreationGraceDays: number;
|
||||
accessGraceDays: number;
|
||||
specialAccess: {
|
||||
kind: 'OPERATOR' | SpecialAccountAccessGrant['kind'];
|
||||
grantId: string | null;
|
||||
expiresAt: string | null;
|
||||
allowsGeneralCreation: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type SpecialAccountAccessGrant = {
|
||||
id: string;
|
||||
userId: string;
|
||||
kind: 'TESTER' | 'RECOVERY' | 'OTHER';
|
||||
profiles: string[];
|
||||
allowsGeneralCreation: boolean;
|
||||
expiresAt?: string;
|
||||
reason: string;
|
||||
grantedByUserId: string;
|
||||
revokedAt?: string;
|
||||
revokedReason?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type AdminPublicUser = {
|
||||
@@ -197,6 +217,7 @@ type AdminClient = {
|
||||
kakaoVerified: boolean;
|
||||
kakaoGraceStartedAt: string;
|
||||
kakaoGraceUntil: string | null;
|
||||
specialAccessGrants: SpecialAccountAccessGrant[];
|
||||
profiles: KakaoGracePolicy[];
|
||||
}>;
|
||||
};
|
||||
@@ -205,6 +226,19 @@ type AdminClient = {
|
||||
kakaoGraceUntil: string | null;
|
||||
}>;
|
||||
};
|
||||
grantSpecialAccess: {
|
||||
mutate: (input: {
|
||||
userId: string;
|
||||
kind: SpecialAccountAccessGrant['kind'];
|
||||
profiles: string[];
|
||||
allowsGeneralCreation: boolean;
|
||||
expiresAt: string | null;
|
||||
reason: string;
|
||||
}) => Promise<SpecialAccountAccessGrant>;
|
||||
};
|
||||
revokeSpecialAccess: {
|
||||
mutate: (input: { userId: string; grantId: string; reason: string }) => Promise<SpecialAccountAccessGrant>;
|
||||
};
|
||||
listHistory: {
|
||||
query: (input: { userId: string; limit?: number }) => Promise<AdminAuditEvent[]>;
|
||||
};
|
||||
@@ -407,6 +441,12 @@ const deletionRetentionDays = ref(30);
|
||||
const kakaoGraceUntil = ref('');
|
||||
const kakaoGraceStatus = ref('');
|
||||
const kakaoPolicies = ref<KakaoGracePolicy[]>([]);
|
||||
const specialAccessGrants = ref<SpecialAccountAccessGrant[]>([]);
|
||||
const specialAccessKind = ref<SpecialAccountAccessGrant['kind']>('RECOVERY');
|
||||
const specialAccessProfiles = ref('');
|
||||
const specialAccessAllowsGeneralCreation = ref(true);
|
||||
const specialAccessExpiresAt = ref('');
|
||||
const specialAccessStatus = ref('');
|
||||
const userHistory = ref<AdminAuditEvent[]>([]);
|
||||
const globalAuditHistory = ref<AdminAuditEvent[]>([]);
|
||||
const globalAuditStatus = ref('');
|
||||
@@ -690,6 +730,7 @@ const lookupUser = async () => {
|
||||
adminClient.users.listHistory.query({ userId: result.id, limit: 50 }),
|
||||
]);
|
||||
kakaoPolicies.value = grace.profiles;
|
||||
specialAccessGrants.value = grace.specialAccessGrants;
|
||||
kakaoGraceUntil.value = grace.kakaoGraceUntil ? toLocalInputValue(grace.kakaoGraceUntil) : '';
|
||||
userHistory.value = history;
|
||||
} catch (error) {
|
||||
@@ -764,12 +805,56 @@ const updateKakaoGrace = async (clear = false) => {
|
||||
kakaoGraceStatus.value = result.kakaoGraceUntil ? 'OAuth 유예 연장 완료' : '개별 유예 해제 완료';
|
||||
const grace = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
|
||||
kakaoPolicies.value = grace.profiles;
|
||||
specialAccessGrants.value = grace.specialAccessGrants;
|
||||
await refreshUserHistory();
|
||||
} catch {
|
||||
kakaoGraceStatus.value = 'OAuth 유예 변경 실패';
|
||||
}
|
||||
};
|
||||
|
||||
const grantSpecialAccess = async () => {
|
||||
if (!userResult.value) return;
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
specialAccessStatus.value = '';
|
||||
try {
|
||||
await adminClient.users.grantSpecialAccess.mutate({
|
||||
userId: userResult.value.id,
|
||||
kind: specialAccessKind.value,
|
||||
profiles: specialAccessProfiles.value
|
||||
.split(',')
|
||||
.map((profile) => profile.trim())
|
||||
.filter(Boolean),
|
||||
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
|
||||
expiresAt: specialAccessExpiresAt.value ? new Date(specialAccessExpiresAt.value).toISOString() : null,
|
||||
reason,
|
||||
});
|
||||
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
|
||||
kakaoPolicies.value = policy.profiles;
|
||||
specialAccessGrants.value = policy.specialAccessGrants;
|
||||
specialAccessStatus.value = '특수 접근 자격을 부여했습니다.';
|
||||
await refreshUserHistory();
|
||||
} catch {
|
||||
specialAccessStatus.value = '특수 접근 자격 부여에 실패했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const revokeSpecialAccess = async (grantId: string) => {
|
||||
if (!userResult.value) return;
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
try {
|
||||
await adminClient.users.revokeSpecialAccess.mutate({ userId: userResult.value.id, grantId, reason });
|
||||
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
|
||||
kakaoPolicies.value = policy.profiles;
|
||||
specialAccessGrants.value = policy.specialAccessGrants;
|
||||
specialAccessStatus.value = '특수 접근 자격을 해제했습니다.';
|
||||
await refreshUserHistory();
|
||||
} catch {
|
||||
specialAccessStatus.value = '특수 접근 자격 해제에 실패했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserPassword = async () => {
|
||||
if (!userResult.value) {
|
||||
return;
|
||||
@@ -1256,6 +1341,84 @@ onMounted(() => {
|
||||
<div class="text-xs text-zinc-500">{{ rolesStatus }}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-amber-800/60 rounded-lg p-5 space-y-4">
|
||||
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
|
||||
<div class="text-xs text-zinc-400">
|
||||
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서
|
||||
서버 범위와 만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다.
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<select
|
||||
v-model="specialAccessKind"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
:disabled="!hasUser"
|
||||
>
|
||||
<option value="RECOVERY">휴대폰 분실·계정 복구</option>
|
||||
<option value="TESTER">특수 테스트</option>
|
||||
<option value="OTHER">기타 예외</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="specialAccessExpiresAt"
|
||||
type="datetime-local"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
:disabled="!hasUser"
|
||||
aria-label="특수 접근 만료 시각"
|
||||
/>
|
||||
<input
|
||||
v-model="specialAccessProfiles"
|
||||
type="text"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
placeholder="che 또는 che:2 (쉼표 구분, 비우면 전체)"
|
||||
:disabled="!hasUser"
|
||||
/>
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-300 px-2">
|
||||
<input
|
||||
v-model="specialAccessAllowsGeneralCreation"
|
||||
type="checkbox"
|
||||
:disabled="!hasUser"
|
||||
/>
|
||||
장수 생성 허용
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
class="bg-amber-600 hover:bg-amber-500 text-black font-semibold px-4 py-2 rounded"
|
||||
:disabled="!hasUser"
|
||||
@click="grantSpecialAccess"
|
||||
>
|
||||
특수 접근 부여
|
||||
</button>
|
||||
<div class="text-xs text-zinc-500">{{ specialAccessStatus }}</div>
|
||||
<div v-if="specialAccessGrants.length" class="space-y-2">
|
||||
<div
|
||||
v-for="grant in specialAccessGrants"
|
||||
:key="grant.id"
|
||||
class="bg-black/30 border border-zinc-800 rounded p-3 text-xs space-y-1"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="font-semibold text-amber-200">
|
||||
{{ grant.kind }} · {{ grant.profiles.length ? grant.profiles.join(', ') : '전체 profile' }}
|
||||
</span>
|
||||
<button
|
||||
v-if="!grant.revokedAt"
|
||||
class="bg-red-900 hover:bg-red-800 text-red-100 px-3 py-1 rounded"
|
||||
@click="revokeSpecialAccess(grant.id)"
|
||||
>
|
||||
해제
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료
|
||||
{{ grant.expiresAt ? new Date(grant.expiresAt).toLocaleString('ko-KR') : '없음' }}
|
||||
</div>
|
||||
<div class="text-zinc-500">부여 사유: {{ grant.reason }}</div>
|
||||
<div v-if="grant.revokedAt" class="text-red-300">
|
||||
해제됨: {{ new Date(grant.revokedAt).toLocaleString('ko-KR') }} ·
|
||||
{{ grant.revokedReason }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||
<h4 class="text-base font-semibold">Kakao 인증 유예</h4>
|
||||
<div class="text-xs text-zinc-500">
|
||||
@@ -1292,6 +1455,7 @@ onMounted(() => {
|
||||
<th>접근</th>
|
||||
<th>장수 생성</th>
|
||||
<th>기본 접근 유예</th>
|
||||
<th>특수 자격</th>
|
||||
<th>종료</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1305,6 +1469,7 @@ onMounted(() => {
|
||||
<td class="text-center">{{ policy.accessAllowed ? '허용' : '차단' }}</td>
|
||||
<td class="text-center">{{ policy.canCreateGeneral ? '허용' : '차단' }}</td>
|
||||
<td class="text-center">{{ policy.accessGraceDays }}일</td>
|
||||
<td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td>
|
||||
<td class="text-center">
|
||||
{{
|
||||
policy.graceEndsAt
|
||||
|
||||
@@ -291,7 +291,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
{{ serverSeasonStatus(profileDetails[profile.profileName]!).label }}
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
v-if="profile.localAccountPolicy?.specialAccess"
|
||||
class="mt-2 text-xs text-emerald-300"
|
||||
>
|
||||
특수 접근 · {{ profile.localAccountPolicy.specialAccess.kind }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
profile.localAccountPolicy?.requiresKakaoVerification &&
|
||||
!profile.localAccountPolicy.canCreateGeneral
|
||||
"
|
||||
|
||||
+21
-1
@@ -11,7 +11,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
||||
| 메뉴 | 경로 | 책임 |
|
||||
| ------------- | ------------------------- | ------------------------------------------------------------------------------ |
|
||||
| 운영 개요 | `/gateway/admin` | 관리 영역 안내와 빠른 진입 |
|
||||
| 사용자 관리 | `/gateway/admin/users` | 계정 조회·생성, 권한, OAuth 유예, 제재, 아이콘 복구, 탈퇴 예약과 사용자별 이력 |
|
||||
| 사용자 관리 | `/gateway/admin/users` | 계정 조회·생성, 권한, 특수 접근·OAuth 유예, 제재, 아이콘 복구, 탈퇴 예약과 사용자별 이력 |
|
||||
| 서버 관리 | `/gateway/admin/servers` | profile 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 |
|
||||
| 버전 업데이트 | `/gateway/admin/releases` | profile DB 유지·초기화 배포, Gateway 릴리스·rollback과 작업 이력 |
|
||||
| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 |
|
||||
@@ -34,5 +34,25 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
||||
- 브라우저의 메뉴 노출은 편의 기능입니다. 권한 판단의 기준은 서버가 인증
|
||||
session에서 해석한 capability입니다.
|
||||
|
||||
## Kakao 없는 특수 계정 접근
|
||||
|
||||
운영자 role(`superuser`, `admin`, `admin.*`)은 별도 grant 없이 모든 game
|
||||
profile에 접근하고 장수를 생성할 수 있습니다. 일반 계정의 예외는 사용자 관리의
|
||||
`특수 접근 부여`에서 다음 항목을 명시합니다.
|
||||
|
||||
- 종류: 특수 테스트(`TESTER`), 인증 수단 복구(`RECOVERY`), 기타(`OTHER`)
|
||||
- profile: 비우면 전체, `che`면 모든 CHE 기수, `che:2`면 해당 기수만
|
||||
- 장수 생성: 단순 기존 장수 접속과 신규 장수 생성 권한을 분리
|
||||
- 만료: `RECOVERY`는 필수이며 현재 시각부터 최대 90일
|
||||
- 사유: 부여·해제 모두 3자 이상이며 감사 원장에 기록
|
||||
|
||||
여러 grant가 있으면 현재 profile에 적용되는 유효 grant 중 하나라도 장수 생성을
|
||||
허용할 때 생성이 가능합니다. 영구 grant가 하나라도 있으면 접근 만료는 없습니다.
|
||||
Kakao 인증이 완료되면 특수 접근이 없어도 정상 접근하며, 계정 제재와 profile별
|
||||
server restriction은 특수 접근보다 먼저 적용됩니다. 변경 시 user flush가 발행되어
|
||||
다음 game token 발급부터 새 정책이 반영됩니다. 기존 Kakao 연결 계정도 비밀번호를
|
||||
확인한 뒤 유효한 grant 기간에는 Kakao 공급자 검증 없이 로그인할 수 있으므로,
|
||||
휴대폰 분실 복구에는 반드시 짧은 만료와 확인 사유를 사용합니다.
|
||||
|
||||
상세 배포·복구 절차는 [릴리스 운영 매뉴얼](./release-operations.md)을
|
||||
따릅니다.
|
||||
|
||||
@@ -26,6 +26,7 @@ TTL, game-token secret, OAuth, local-account 정책과 orchestrator 설정을
|
||||
Gateway API는 다음 저장 경계를 사용합니다.
|
||||
|
||||
- `AppUser`, `SystemSetting`: 계정과 정책
|
||||
- `SpecialAccountAccessGrant`: Kakao 없는 테스트·복구·기타 계정의 profile 범위, 만료, 장수 생성 및 해제 이력
|
||||
- `GatewayProfile`: profile, scenario, port, 상태와 build 결과
|
||||
- `GatewayOperation`: build/reset/open/close 등 실행 요청과 결과
|
||||
- `GatewayReleaseOperation`, `GatewayReleaseState`: Gateway 전체 릴리스 queue와 현재·이전 commit
|
||||
@@ -67,6 +68,16 @@ Redis script가 원자적으로 성공 소비 또는 실패 횟수 차감(최대
|
||||
challenge와 OAuth pending state에는 TTL이 있으며 Redis 장애나 메시지 발송 실패는
|
||||
로그인 실패로 끝납니다.
|
||||
|
||||
Kakao 없는 게임 접근은 Gateway에서만 판정합니다. 비밀번호와 제재를 먼저 검사한 뒤
|
||||
운영자 또는 유효한 특수 grant가 있는 기존 Kakao 연결 계정은 공급자 호출 없이
|
||||
Gateway session을 발급합니다. 게임 profile 진입에서는 다시 제재 검사를 수행하고
|
||||
Kakao 확인, 운영자 role, 유효한 `SpecialAccountAccessGrant`, 기존 일반 계정 유예
|
||||
순으로 접근과 장수 생성 가능 여부를 계산합니다. grant의 빈 `profiles`는 전체,
|
||||
base profile(`che`)은 모든 기수, profile name(`che:2`)은 정확한 기수를 뜻합니다.
|
||||
결과는 AES-256-GCM game token의 `identity.specialAccess`와
|
||||
`identity.canCreateGeneral`에 서명되어 game API가 장수 생성 mutation 전에 다시
|
||||
검사합니다. grant 사유나 부여자 정보는 game token에 넣지 않습니다.
|
||||
|
||||
Orchestrator는 `GatewayOperation`을 claim하고 source ref를 commit으로
|
||||
해결합니다. `WorkspaceManager`가 commit별 worktree를 준비하고 build runner가
|
||||
artifact를 만들며 `Pm2ProcessManager`가 profile process를 조정합니다.
|
||||
|
||||
@@ -55,6 +55,10 @@ export interface GameSessionTokenPayload {
|
||||
canCreateGeneral: boolean;
|
||||
requiresKakaoVerification: boolean;
|
||||
graceEndsAt: string | null;
|
||||
specialAccess?: {
|
||||
kind: 'OPERATOR' | 'TESTER' | 'RECOVERY' | 'OTHER';
|
||||
expiresAt: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,7 +140,13 @@ export const parseGameSessionTokenPayload = (value: unknown): GameSessionTokenPa
|
||||
typeof identity.kakaoVerified !== 'boolean' ||
|
||||
typeof identity.canCreateGeneral !== 'boolean' ||
|
||||
typeof identity.requiresKakaoVerification !== 'boolean' ||
|
||||
(identity.graceEndsAt !== null && typeof identity.graceEndsAt !== 'string')
|
||||
(identity.graceEndsAt !== null && typeof identity.graceEndsAt !== 'string') ||
|
||||
(identity.specialAccess !== undefined &&
|
||||
(!identity.specialAccess ||
|
||||
typeof identity.specialAccess !== 'object' ||
|
||||
!['OPERATOR', 'TESTER', 'RECOVERY', 'OTHER'].includes(identity.specialAccess.kind) ||
|
||||
(identity.specialAccess.expiresAt !== null &&
|
||||
typeof identity.specialAccess.expiresAt !== 'string')))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE TYPE "SpecialAccountAccessKind" AS ENUM ('TESTER', 'RECOVERY', 'OTHER');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "special_account_access_grant" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"user_id" TEXT NOT NULL,
|
||||
"kind" "SpecialAccountAccessKind" NOT NULL,
|
||||
"profiles" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
"allows_general_creation" BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
"expires_at" TIMESTAMP(3),
|
||||
"reason" TEXT NOT NULL,
|
||||
"granted_by_user_id" TEXT NOT NULL,
|
||||
"revoked_at" TIMESTAMP(3),
|
||||
"revoked_by_user_id" TEXT,
|
||||
"revoked_reason" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "special_account_access_grant_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "special_account_access_grant_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "app_user"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "special_account_access_grant_user_id_revoked_at_expires_at_idx"
|
||||
ON "special_account_access_grant"("user_id", "revoked_at", "expires_at");
|
||||
CREATE INDEX IF NOT EXISTS "special_account_access_grant_kind_created_at_idx"
|
||||
ON "special_account_access_grant"("kind", "created_at");
|
||||
|
||||
COMMENT ON TABLE "special_account_access_grant" IS
|
||||
'Explicit audited exception allowing selected local accounts to enter game profiles without Kakao verification.';
|
||||
@@ -65,6 +65,12 @@ enum AdminAuditOutcome {
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum SpecialAccountAccessKind {
|
||||
TESTER
|
||||
RECOVERY
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum GatewaySourceMode {
|
||||
BRANCH
|
||||
COMMIT
|
||||
@@ -101,10 +107,31 @@ model AppUser {
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
|
||||
icons UserIcon[]
|
||||
specialAccessGrants SpecialAccountAccessGrant[]
|
||||
|
||||
@@map("app_user")
|
||||
}
|
||||
|
||||
model SpecialAccountAccessGrant {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
kind SpecialAccountAccessKind
|
||||
profiles String[] @default([])
|
||||
allowsGeneralCreation Boolean @default(true) @map("allows_general_creation")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
reason String
|
||||
grantedByUserId String @map("granted_by_user_id")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
revokedByUserId String? @map("revoked_by_user_id")
|
||||
revokedReason String? @map("revoked_reason")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([userId, revokedAt, expiresAt])
|
||||
@@index([kind, createdAt])
|
||||
@@map("special_account_access_grant")
|
||||
}
|
||||
|
||||
model AdminAuditEvent {
|
||||
id String @id @default(uuid())
|
||||
correlationId String @map("correlation_id")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"controllerProtocol": 1,
|
||||
"gatewaySchemaHead": "20260808000000_add_kakao_talk_verification",
|
||||
"gatewaySchemaHead": "20260808001000_add_special_account_access_grants",
|
||||
"gameSchemaHead": "20260803000000_add_logical_game_clock",
|
||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user