improve runtime environment variable modal usability
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-14 15:08:50 +09:00
parent d922b873e4
commit f4a748b5f3
5 changed files with 174 additions and 4 deletions
+22
View File
@@ -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")
+23 -1
View File
@@ -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);
}
}
+2 -1
View File
@@ -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],
})
+115 -1
View File
@@ -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 },
+12 -1
View File
@@ -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;
}