import { createHash, randomBytes, scryptSync } from 'node:crypto'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; import { GiteaService } from '../gitea/gitea.service'; import { PrismaService } from '../prisma/prisma.service'; import { getUserAvatarUrl } from '../users/users.service'; import { AdminRole } from './admin-role'; import { CreateAdminSshKeyDto, CreateAdminDto, ResetAdminPasswordDto, UpdateAdminAppPermissionsDto, UpdateAdminRoleDto, UpdateAdminStatusDto, } from './dto'; @Injectable() export class AdminsService { constructor( private readonly authPrisma: AuthPrismaService, private readonly configService: ConfigService, private readonly giteaService: GiteaService, private readonly prisma: PrismaService, ) {} async listRoles() { await this.ensureDefaultRoles(); return this.prisma.adminRoleOption.findMany({ where: { isActive: true }, orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }], select: { id: true, code: true, name: true, description: true, sortOrder: true, isActive: true, }, }); } async list() { const admins = await this.prisma.admin.findMany({ orderBy: [{ role: 'asc' }, { createdAt: 'desc' }], select: { id: true, email: true, name: true, role: true, isActive: true, lastLoginAt: true, createdAt: true, updatedAt: true, sessions: { orderBy: { createdAt: 'desc' }, take: 1, select: { id: true, expiresAt: true, revokedAt: true, createdAt: true, }, }, sshKeys: { orderBy: { createdAt: 'desc' }, select: adminSshKeySelect, }, appPermissions: { select: { appId: true }, }, }, }); const users = await this.authPrisma.user.findMany({ where: { email: { in: admins.map((admin) => admin.email) } }, select: { id: true, email: true, profileImageMime: true, }, }); const userByEmail = new Map(users.map((user) => [user.email, user])); const authBaseUrl = this.configService.get('AUTH_PUBLIC_URL') ?? this.configService.get('ATNMY_AUTH_URL') ?? this.configService.get('AUTH_EXTERNAL_URL') ?? 'https://auth.autonomystudio.net'; return admins.map((admin) => { const user = userByEmail.get(admin.email); return { ...admin, avatarUrl: getUserAvatarUrl(user?.id ?? '', user?.profileImageMime, authBaseUrl), }; }); } async create(body: CreateAdminDto) { await this.ensureRoleEnabled(body.role); const email = body.email.trim().toLowerCase(); const name = body.name.trim(); await this.giteaService.createUser({ username: createGiteaUsername(email), email, password: body.temporaryPassword, fullName: name, }); const admin = await this.prisma.admin.create({ data: { email, name, role: body.role, passwordHash: hashPassword(body.temporaryPassword), }, select: adminSelect, }); return { admin }; } async updateStatus(id: string, body: UpdateAdminStatusDto) { await this.ensureExists(id); const admin = await this.prisma.admin.update({ where: { id }, data: { isActive: body.isActive }, select: adminSelect, }); if (!body.isActive) { await this.prisma.adminSession.updateMany({ where: { adminId: id, revokedAt: null }, data: { revokedAt: new Date() }, }); } return { admin }; } async updateRole(id: string, body: UpdateAdminRoleDto) { await this.ensureExists(id); await this.ensureRoleEnabled(body.role); const admin = await this.prisma.admin.update({ where: { id }, data: { role: body.role }, select: adminSelect, }); return { admin }; } async resetPassword(id: string, body: ResetAdminPasswordDto) { await this.ensureExists(id); const admin = await this.prisma.admin.update({ where: { id }, data: { passwordHash: hashPassword(body.temporaryPassword) }, select: adminSelect, }); await this.prisma.adminSession.updateMany({ where: { adminId: id, revokedAt: null }, data: { revokedAt: new Date() }, }); return { admin }; } async listAppPermissions(id: string) { await this.ensureExists(id); const permissions = await this.prisma.adminAppPermission.findMany({ where: { adminId: id }, orderBy: { createdAt: 'asc' }, select: { appId: true }, }); return { appIds: permissions.map((permission) => permission.appId) }; } async updateAppPermissions(id: string, body: UpdateAdminAppPermissionsDto) { const admin = await this.prisma.admin.findUnique({ where: { id }, select: { id: true, role: true }, }); if (!admin) { throw new NotFoundException('관리자를 찾을 수 없습니다.'); } if (admin.role === AdminRole.OWNER) { throw new BadRequestException('오너 권한은 앱 권한을 개별 설정하지 않습니다.'); } const appIds = [...new Set(body.appIds.filter(Boolean))]; const existingApps = await this.prisma.app.findMany({ where: { id: { in: appIds } }, select: { id: true }, }); const existingAppIds = new Set(existingApps.map((app) => app.id)); const validAppIds = appIds.filter((appId) => existingAppIds.has(appId)); await this.prisma.$transaction([ this.prisma.adminAppPermission.deleteMany({ where: { adminId: id } }), ...(validAppIds.length ? [ this.prisma.adminAppPermission.createMany({ data: validAppIds.map((appId) => ({ adminId: id, appId })), skipDuplicates: true, }), ] : []), ]); return { appIds: validAppIds }; } async createSshKey(adminId: string, body: CreateAdminSshKeyDto) { const admin = await this.ensureExists(adminId); const parsedKey = parseSshPublicKey(body.publicKey); const label = body.label?.trim() || `${parsedKey.keyType} ${parsedKey.fingerprint}`; const giteaUsername = createGiteaUsername(admin.email); let giteaKeyId: number | null = null; const giteaKey = await this.giteaService.createUserSshKey(giteaUsername, { title: label, key: parsedKey.normalizedKey, }); giteaKeyId = giteaKey.id; const sshKey = await this.prisma.adminSshKey.create({ data: { adminId, label, keyType: parsedKey.keyType, publicKey: parsedKey.normalizedKey, publicKeyHash: parsedKey.publicKeyHash, fingerprint: parsedKey.fingerprint, giteaUsername, giteaKeyId, }, select: adminSshKeySelect, }); return { sshKey }; } async deleteSshKey(adminId: string, keyId: string) { await this.ensureExists(adminId); const sshKey = await this.prisma.adminSshKey.findFirst({ where: { id: keyId, adminId }, select: { id: true, giteaUsername: true, giteaKeyId: true, }, }); if (!sshKey) { throw new NotFoundException('SSH 키를 찾을 수 없습니다.'); } await this.prisma.adminSshKey.delete({ where: { id: keyId } }); if (sshKey.giteaUsername && sshKey.giteaKeyId) { await this.giteaService.deleteUserSshKey( sshKey.giteaUsername, sshKey.giteaKeyId, ); } return { deleted: true }; } private async ensureRoleEnabled(role: AdminRole) { await this.ensureDefaultRoles(); const roleOption = await this.prisma.adminRoleOption.findUnique({ where: { code: role }, select: { isActive: true }, }); if (!roleOption?.isActive) { throw new BadRequestException('사용할 수 없는 관리자 권한입니다.'); } } private async ensureDefaultRoles() { await this.prisma.adminRoleOption.createMany({ data: [ { code: AdminRole.OWNER, name: 'OWNER', description: '전체 관리자 권한', sortOrder: 1 }, { code: AdminRole.ADMIN, name: 'ADMIN', description: '관리자 권한', sortOrder: 2 }, { code: AdminRole.VIEWER, name: 'VIEWER', description: '조회 권한', sortOrder: 3 }, ], skipDuplicates: true, }); } private async ensureExists(id: string) { const admin = await this.prisma.admin.findUnique({ where: { id }, select: { id: true, email: true }, }); if (!admin) { throw new NotFoundException('관리자를 찾을 수 없습니다.'); } return admin; } } const adminSelect = { id: true, email: true, name: true, role: true, isActive: true, lastLoginAt: true, createdAt: true, updatedAt: true, } as const; const adminSshKeySelect = { id: true, label: true, keyType: true, fingerprint: true, giteaUsername: true, giteaKeyId: true, createdAt: true, } as const; function hashPassword(password: string) { const salt = randomBytes(16).toString('hex'); const hash = scryptSync(password, salt, 64).toString('hex'); return `scrypt:${salt}:${hash}`; } function createGiteaUsername(email: string) { const account = email.split('@')[0] || email; const username = account .trim() .toLowerCase() .replace(/[^a-z0-9._-]+/g, '-') .replace(/^[._-]+|[._-]+$/g, '') .slice(0, 40); return username || `admin-${createHash('sha1').update(email).digest('hex').slice(0, 8)}`; } function parseSshPublicKey(publicKey: string) { const normalizedKey = publicKey.trim().replace(/\s+/g, ' '); const [keyType, keyBody] = normalizedKey.split(' '); if (!keyType || !keyBody || !isSupportedSshKeyType(keyType)) { throw new BadRequestException('지원하지 않는 SSH 공개키 형식입니다.'); } let keyBuffer: Buffer; try { keyBuffer = Buffer.from(keyBody, 'base64'); } catch { throw new BadRequestException('SSH 공개키 base64 값을 확인하세요.'); } if (!keyBuffer.length) { throw new BadRequestException('SSH 공개키 값이 비어 있습니다.'); } return { keyType, normalizedKey, publicKeyHash: createHash('sha256').update(normalizedKey).digest('hex'), fingerprint: `SHA256:${createHash('sha256') .update(keyBuffer) .digest('base64') .replace(/=+$/, '')}`, }; } function isSupportedSshKeyType(keyType: string) { return ( keyType === 'ssh-ed25519' || keyType === 'ssh-rsa' || keyType.startsWith('ecdsa-sha2-') ); }