Files
studio-admin-be/src/admins/admins.service.ts
T
chyoo 3873ce3169
ci/woodpecker/push/woodpecker Pipeline was successful
track requesters for builds releases deployments and migrations
2026-07-10 10:43:02 +09:00

397 lines
10 KiB
TypeScript

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<string>('AUTH_PUBLIC_URL')
?? this.configService.get<string>('ATNMY_AUTH_URL')
?? this.configService.get<string>('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-')
);
}