init studio admin backend

This commit is contained in:
2026-07-01 17:30:04 +09:00
commit 4437d9980d
144 changed files with 44964 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
export const AdminRole = {
OWNER: 'OWNER',
ADMIN: 'ADMIN',
OPERATOR: 'OPERATOR',
VIEWER: 'VIEWER',
} as const;
export type AdminRole = (typeof AdminRole)[keyof typeof AdminRole];
+58
View File
@@ -0,0 +1,58 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import {
CreateAdminSshKeyDto,
CreateAdminDto,
ResetAdminPasswordDto,
UpdateAdminRoleDto,
UpdateAdminStatusDto,
} from './dto';
import { AdminRoles } from '../admin-auth/decorators/admin-auth.decorators';
import { AdminRole } from './admin-role';
import { AdminsService } from './admins.service';
@AdminRoles(AdminRole.OWNER)
@Controller('admins')
export class AdminsController {
constructor(private readonly adminsService: AdminsService) {}
@Get('roles')
listRoles() {
return this.adminsService.listRoles();
}
@Get()
list() {
return this.adminsService.list();
}
@Post()
create(@Body() body: CreateAdminDto) {
return this.adminsService.create(body);
}
@Patch(':id/status')
updateStatus(@Param('id') id: string, @Body() body: UpdateAdminStatusDto) {
return this.adminsService.updateStatus(id, body);
}
@Patch(':id/role')
updateRole(@Param('id') id: string, @Body() body: UpdateAdminRoleDto) {
return this.adminsService.updateRole(id, body);
}
@Patch(':id/password')
resetPassword(@Param('id') id: string, @Body() body: ResetAdminPasswordDto) {
return this.adminsService.resetPassword(id, body);
}
@Post(':id/ssh-keys')
createSshKey(@Param('id') id: string, @Body() body: CreateAdminSshKeyDto) {
return this.adminsService.createSshKey(id, body);
}
@Delete(':id/ssh-keys/:keyId')
deleteSshKey(@Param('id') id: string, @Param('keyId') keyId: string) {
return this.adminsService.deleteSshKey(id, keyId);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { GiteaModule } from '../gitea/gitea.module';
import { PrismaModule } from '../prisma/prisma.module';
import { AdminsController } from './admins.controller';
import { AdminsService } from './admins.service';
@Module({
imports: [GiteaModule, PrismaModule],
controllers: [AdminsController],
providers: [AdminsService],
})
export class AdminsModule {}
+295
View File
@@ -0,0 +1,295 @@
import { createHash, randomBytes, scryptSync } from 'node:crypto';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { GiteaService } from '../gitea/gitea.service';
import { PrismaService } from '../prisma/prisma.service';
import { AdminRole } from './admin-role';
import {
CreateAdminSshKeyDto,
CreateAdminDto,
ResetAdminPasswordDto,
UpdateAdminRoleDto,
UpdateAdminStatusDto,
} from './dto';
@Injectable()
export class AdminsService {
constructor(
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,
},
});
}
list() {
return 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,
},
},
});
}
async create(body: CreateAdminDto) {
await this.ensureRoleEnabled(body.role);
const admin = await this.prisma.admin.create({
data: {
email: body.email.trim().toLowerCase(),
name: body.name.trim(),
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 createSshKey(adminId: string, body: CreateAdminSshKeyDto) {
await this.ensureExists(adminId);
const parsedKey = parseSshPublicKey(body.publicKey);
const label = body.label?.trim() || `${parsedKey.keyType} ${parsedKey.fingerprint}`;
const giteaUsername = body.giteaUsername?.trim() || null;
let giteaKeyId: number | null = null;
if (giteaUsername) {
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 },
});
if (!admin) {
throw new NotFoundException('관리자를 찾을 수 없습니다.');
}
}
}
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 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-')
);
}
+46
View File
@@ -0,0 +1,46 @@
import { IsBoolean, IsEmail, IsEnum, IsOptional, IsString, MinLength } from 'class-validator';
import { AdminRole } from './admin-role';
export class CreateAdminDto {
@IsEmail()
email!: string;
@IsString()
name!: string;
@IsEnum(AdminRole)
role!: AdminRole;
@IsString()
@MinLength(8)
temporaryPassword!: string;
}
export class UpdateAdminStatusDto {
@IsBoolean()
isActive!: boolean;
}
export class UpdateAdminRoleDto {
@IsEnum(AdminRole)
role!: AdminRole;
}
export class ResetAdminPasswordDto {
@IsString()
@MinLength(8)
temporaryPassword!: string;
}
export class CreateAdminSshKeyDto {
@IsOptional()
@IsString()
label?: string;
@IsString()
publicKey!: string;
@IsOptional()
@IsString()
giteaUsername?: string;
}