init studio admin backend
This commit is contained in:
@@ -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];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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-')
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user