feat(gateway): add special account access

This commit is contained in:
2026-08-08 16:33:54 +00:00
parent 6a333cdf05
commit 77b1051ac2
22 changed files with 1060 additions and 15 deletions
@@ -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}`);
}
+71 -7
View File
@@ -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,