From f4a748b5f3d206e7994dafb11f548aa69c83032b Mon Sep 17 00:00:00 2001 From: chyoo Date: Tue, 14 Jul 2026 15:08:50 +0900 Subject: [PATCH] improve runtime environment variable modal usability --- prisma/launcher.schema.prisma | 22 ++++++ src/agents/agents.controller.ts | 24 ++++++- src/agents/agents.module.ts | 3 +- src/agents/agents.service.ts | 116 +++++++++++++++++++++++++++++++- src/agents/dto.ts | 13 +++- 5 files changed, 174 insertions(+), 4 deletions(-) diff --git a/prisma/launcher.schema.prisma b/prisma/launcher.schema.prisma index ff86a6f..5f35426 100644 --- a/prisma/launcher.schema.prisma +++ b/prisma/launcher.schema.prisma @@ -88,6 +88,28 @@ model ProvisionedDevice { @@map("provisioned_devices") } +model DeviceAuthorization { + id String @id @default(uuid()) @db.Uuid + deviceCodeHash String @unique @map("device_code_hash") + userCode String @unique @map("user_code") + status String @default("PENDING") + authUserId String? @map("auth_user_id") @db.Uuid + accountEmail String? @map("account_email") + authUserName String? @map("auth_user_name") + authUserRole String? @map("auth_user_role") + authUserRoles Json? @map("auth_user_roles") + device Json? + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + approvedAt DateTime? @map("approved_at") @db.Timestamptz(3) + consumedAt DateTime? @map("consumed_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([status, expiresAt]) + @@index([authUserId, createdAt]) + @@map("device_authorizations") +} + model LauncherSshKey { id String @id @default(uuid()) @db.Uuid accountEmail String @map("account_email") diff --git a/src/agents/agents.controller.ts b/src/agents/agents.controller.ts index f336774..b0a0720 100644 --- a/src/agents/agents.controller.ts +++ b/src/agents/agents.controller.ts @@ -1,7 +1,9 @@ -import { Controller, Delete, Get, Param, Patch, Query, Body } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ListLauncherSshKeysQuery, + CreateDeviceAuthorizationDto, + ListDeviceAuthorizationUsersQuery, ListProvisionedDevicesQuery, UpdateProvisionedDeviceDto, } from './dto'; @@ -38,4 +40,24 @@ export class AgentsController { removeSshKey(@Param('id') id: string) { return this.agentsService.removeSshKey(id); } + + @Get('registration-users') + listRegistrationUsers(@Query() query: ListDeviceAuthorizationUsersQuery) { + return this.agentsService.listRegistrationUsers(query); + } + + @Get('device-authorizations') + listDeviceAuthorizations() { + return this.agentsService.listDeviceAuthorizations(); + } + + @Post('device-authorizations') + createDeviceAuthorization(@Body() body: CreateDeviceAuthorizationDto) { + return this.agentsService.createDeviceAuthorization(body.userId); + } + + @Post('device-authorizations/:id/cancel') + cancelDeviceAuthorization(@Param('id') id: string) { + return this.agentsService.cancelDeviceAuthorization(id); + } } diff --git a/src/agents/agents.module.ts b/src/agents/agents.module.ts index db14c5e..7d24a76 100644 --- a/src/agents/agents.module.ts +++ b/src/agents/agents.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; +import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module'; import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module'; import { AgentsController } from './agents.controller'; import { AgentsService } from './agents.service'; @Module({ - imports: [LauncherPrismaModule], + imports: [AuthPrismaModule, LauncherPrismaModule], controllers: [AgentsController], providers: [AgentsService], }) diff --git a/src/agents/agents.service.ts b/src/agents/agents.service.ts index f7d4e4d..01e971e 100644 --- a/src/agents/agents.service.ts +++ b/src/agents/agents.service.ts @@ -1,7 +1,10 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { createHash, randomBytes } from 'node:crypto'; +import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service'; import { + ListDeviceAuthorizationUsersQuery, ListLauncherSshKeysQuery, ListProvisionedDevicesQuery, UpdateProvisionedDeviceDto, @@ -9,7 +12,10 @@ import { @Injectable() export class AgentsService { - constructor(private readonly launcherPrisma: LauncherPrismaService) {} + constructor( + private readonly authPrisma: AuthPrismaService, + private readonly launcherPrisma: LauncherPrismaService, + ) {} async listDevices(query: ListProvisionedDevicesQuery) { const keyword = query.keyword?.trim(); @@ -109,6 +115,114 @@ export class AgentsService { return { ok: true }; } + async listRegistrationUsers(query: ListDeviceAuthorizationUsersQuery) { + const keyword = query.keyword?.trim(); + const users = await this.authPrisma.user.findMany({ + where: { + role: 'USER', + isActive: true, + ...(keyword + ? { + OR: [ + { email: { contains: keyword, mode: 'insensitive' } }, + { name: { contains: keyword, mode: 'insensitive' } }, + ], + } + : {}), + }, + orderBy: [{ name: 'asc' }, { email: 'asc' }], + take: 20, + select: { id: true, name: true, email: true }, + }); + + return { users }; + } + + async listDeviceAuthorizations() { + const now = new Date(); + const authorizations = await this.launcherPrisma.deviceAuthorization.findMany({ + where: { + OR: [ + { status: { in: ['APPROVED', 'CONSUMING'] } }, + { createdAt: { gte: new Date(now.getTime() - 24 * 60 * 60 * 1000) } }, + ], + }, + orderBy: { createdAt: 'desc' }, + take: 50, + select: { + id: true, + userCode: true, + status: true, + authUserId: true, + accountEmail: true, + authUserName: true, + expiresAt: true, + createdAt: true, + }, + }); + + return { + authorizations: authorizations.map((authorization) => ({ + ...authorization, + status: authorization.status === 'APPROVED' && authorization.expiresAt <= now ? 'EXPIRED' : authorization.status, + })), + }; + } + + async createDeviceAuthorization(userId: string) { + const user = await this.authPrisma.user.findFirst({ + where: { id: userId, role: 'USER', isActive: true }, + select: { id: true, email: true, name: true, role: true }, + }); + + if (!user) { + throw new NotFoundException('플랫폼 등록 대상 사용자를 찾을 수 없습니다.'); + } + + const now = new Date(); + const expiresAt = new Date(now.getTime() + 5 * 60 * 1000); + await this.launcherPrisma.deviceAuthorization.updateMany({ + where: { + authUserId: user.id, + accountEmail: user.email, + status: 'APPROVED', + expiresAt: { gt: now }, + }, + data: { status: 'CANCELLED' }, + }); + + const authorization = await this.launcherPrisma.deviceAuthorization.create({ + data: { + deviceCodeHash: createHash('sha256').update(randomBytes(32).toString('base64url')).digest('hex'), + userCode: String(randomBytes(4).readUInt32BE() % 1_000_000).padStart(6, '0'), + status: 'APPROVED', + authUserId: user.id, + accountEmail: user.email, + authUserName: user.name, + authUserRole: user.role, + authUserRoles: [user.role], + expiresAt, + approvedAt: now, + }, + select: { id: true, userCode: true, expiresAt: true, accountEmail: true, authUserName: true, status: true }, + }); + + return { authorization }; + } + + async cancelDeviceAuthorization(id: string) { + const result = await this.launcherPrisma.deviceAuthorization.updateMany({ + where: { id, status: { in: ['APPROVED', 'CONSUMING'] } }, + data: { status: 'CANCELLED' }, + }); + + if (!result.count) { + throw new NotFoundException('취소할 플랫폼 등록 키를 찾을 수 없습니다.'); + } + + return { ok: true }; + } + private async ensureDeviceExists(id: string) { const device = await this.launcherPrisma.provisionedDevice.findUnique({ where: { id }, diff --git a/src/agents/dto.ts b/src/agents/dto.ts index cce6516..2b80900 100644 --- a/src/agents/dto.ts +++ b/src/agents/dto.ts @@ -1,4 +1,4 @@ -import { IsOptional, IsString } from 'class-validator'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; export class ListProvisionedDevicesQuery { @IsOptional() @@ -37,3 +37,14 @@ export class ListLauncherSshKeysQuery { @IsString() keyword?: string; } + +export class ListDeviceAuthorizationUsersQuery { + @IsOptional() + @IsString() + keyword?: string; +} + +export class CreateDeviceAuthorizationDto { + @IsUUID() + userId!: string; +}