Files
core2026/app/gateway-api/src/auth/localAccountPolicy.ts
T
Hide_D fc7de05017 feat: 레거시 재이관과 계정 복구 기반을 추가
중앙 이전 기록 스키마와 버전 정규화기를 도입하고, 재실행 시 현행 계정 상태를 보존한다. 카카오 인증 뒤 이관 비밀번호를 1회 설정하는 흐름과 비카카오 계정용 안전한 CLI 복구 경로를 추가한다.
2026-08-17 15:55:32 +00:00

137 lines
5.3 KiB
TypeScript

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']);
const DAY_MS = 24 * 60 * 60 * 1000;
export interface LocalAccountProfilePolicy {
requiresKakaoVerification: boolean;
kakaoVerified: boolean;
accessAllowed: boolean;
canCreateGeneral: boolean;
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 => {
const value = meta[key];
if (typeof value !== 'number' || !Number.isFinite(value)) {
return fallback;
}
return Math.min(Math.max(Math.floor(value), 0), 365);
};
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);
const generalCreationDefault = GENERAL_CREATION_GRACE_PROFILES.has(profile) ? defaultGraceDays : 0;
const generalCreationGraceDays = readGraceDays(
meta,
'localAccountGeneralCreationGraceDays',
generalCreationDefault
);
const kakaoVerified =
options.user.oauthType === 'KAKAO' &&
Boolean(options.user.oauthId?.trim()) &&
Boolean(options.user.kakaoVerifiedAt);
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 || specialAccess !== null || now < accessEndsAt;
const canCreateGeneral =
kakaoVerified ||
specialAccess?.allowsGeneralCreation === true ||
(accessAllowed && now < generalCreationEndsAt);
return {
requiresKakaoVerification: !kakaoVerified && specialAccess === null,
kakaoVerified,
accessAllowed,
canCreateGeneral,
graceEndsAt: kakaoVerified ? null : specialAccess ? specialAccess.expiresAt : accessEndsAt.toISOString(),
generalCreationGraceDays,
accessGraceDays,
specialAccess,
};
};