From e36b0ca5283f863e8ddd785b035334265abdbd38 Mon Sep 17 00:00:00 2001 From: chyoo Date: Fri, 10 Jul 2026 15:09:46 +0900 Subject: [PATCH] improve launcher auth and remote connection feedback --- k8s/launcher-be.yaml | 38 ++++++++++++ k8s/launcher-db.yaml | 62 +++++++++++++++++++ k8s/launcher-ingress.yaml | 20 ++++++ prisma/schema.prisma | 15 +++++ src/app.module.ts | 2 + src/apps/apps.service.ts | 22 +++++++ .../dto/create-session-provisioning.dto.ts | 53 ++++++++++++++++ src/provisioning/provisioning.controller.ts | 17 +++++ src/provisioning/provisioning.service.ts | 60 +++++++++++++++++- src/terms/terms.controller.ts | 12 ++++ src/terms/terms.module.ts | 11 ++++ src/terms/terms.service.ts | 25 ++++++++ 12 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 k8s/launcher-be.yaml create mode 100644 k8s/launcher-db.yaml create mode 100644 k8s/launcher-ingress.yaml create mode 100644 src/provisioning/dto/create-session-provisioning.dto.ts create mode 100644 src/terms/terms.controller.ts create mode 100644 src/terms/terms.module.ts create mode 100644 src/terms/terms.service.ts diff --git a/k8s/launcher-be.yaml b/k8s/launcher-be.yaml new file mode 100644 index 0000000..945ce8f --- /dev/null +++ b/k8s/launcher-be.yaml @@ -0,0 +1,38 @@ +apiVersion: v1 +kind: Service +metadata: + name: studio-launcher-be + namespace: studio-system +spec: + selector: + app: studio-launcher-be + ports: + - name: http + port: 4300 + targetPort: 4300 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: studio-launcher-be + namespace: studio-system +spec: + replicas: 1 + selector: + matchLabels: + app: studio-launcher-be + template: + metadata: + labels: + app: studio-launcher-be + spec: + containers: + - name: studio-launcher-be + image: localhost:5000/studio-launcher-be:0.1.0 + imagePullPolicy: Always + ports: + - containerPort: 4300 + envFrom: + - secretRef: + name: studio-launcher-be-env + diff --git a/k8s/launcher-db.yaml b/k8s/launcher-db.yaml new file mode 100644 index 0000000..2efa579 --- /dev/null +++ b/k8s/launcher-db.yaml @@ -0,0 +1,62 @@ +apiVersion: v1 +kind: Secret +metadata: + name: studio-launcher-db-secret + namespace: studio-system +type: Opaque +stringData: + POSTGRES_USER: launcher + POSTGRES_PASSWORD: future1234!@ + POSTGRES_DB: launcher +--- +apiVersion: v1 +kind: Service +metadata: + name: studio-launcher-db + namespace: studio-system +spec: + selector: + app: studio-launcher-db + ports: + - name: postgres + port: 5432 + targetPort: 5432 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: studio-launcher-db + namespace: studio-system +spec: + serviceName: studio-launcher-db + replicas: 1 + selector: + matchLabels: + app: studio-launcher-db + template: + metadata: + labels: + app: studio-launcher-db + spec: + containers: + - name: postgres + image: postgres:17 + ports: + - containerPort: 5432 + envFrom: + - secretRef: + name: studio-launcher-db-secret + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + subPath: postgres + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: local-path + resources: + requests: + storage: 3Gi + diff --git a/k8s/launcher-ingress.yaml b/k8s/launcher-ingress.yaml new file mode 100644 index 0000000..d9984eb --- /dev/null +++ b/k8s/launcher-ingress.yaml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: studio-launcher-ingress + namespace: studio-system +spec: + ingressClassName: traefik + rules: + - host: portal-api.autonomystudio.net + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: studio-launcher-be + port: + number: 4300 + + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a07ad44..56a8a11 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,21 @@ model LauncherNotice { @@map("launcher_notices") } +model LauncherTerm { + id String @id @default(uuid()) @db.Uuid + key String @unique + body String + isRequired Boolean @default(true) @map("is_required") + createdById String? @map("created_by_id") @db.Uuid + createdByName String? @map("created_by_name") + createdByEmail String? @map("created_by_email") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([createdByEmail]) + @@map("launcher_terms") +} + model LauncherAppVersion { id String @id @default(uuid()) @db.Uuid appKey String @unique @map("app_key") diff --git a/src/app.module.ts b/src/app.module.ts index 29d8a61..cee23d9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,6 +10,7 @@ import { NoticesModule } from './notices/notices.module'; import { NotificationsModule } from './notifications/notifications.module'; import { DownloadsModule } from './downloads/downloads.module'; import { ProvisioningModule } from './provisioning/provisioning.module'; +import { TermsModule } from './terms/terms.module'; @Module({ imports: [ @@ -25,6 +26,7 @@ import { ProvisioningModule } from './provisioning/provisioning.module'; DownloadsModule, ProvisioningModule, AppsModule, + TermsModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/apps/apps.service.ts b/src/apps/apps.service.ts index 2ea15c8..2442320 100644 --- a/src/apps/apps.service.ts +++ b/src/apps/apps.service.ts @@ -61,6 +61,10 @@ export class AppsService { async getApps(accessToken?: string) { const authUserId = await this.getAuthUserId(accessToken); + if (authUserId) { + await this.ensureDefaultAppViewPermissions(authUserId); + } + const apps = await this.prisma.launcherApp.findMany({ where: { isEnabled: true }, orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }], @@ -296,4 +300,22 @@ export class AppsService { return Boolean(permission); } + + private async ensureDefaultAppViewPermissions(authUserId: string) { + const apps = await this.prisma.launcherApp.findMany({ + where: { isEnabled: true }, + select: { id: true }, + }); + if (!apps.length) return; + + await this.prisma.launcherUserApp.createMany({ + data: apps.map((app) => ({ + authUserId, + appId: app.id, + canView: true, + canLaunch: false, + })), + skipDuplicates: true, + }); + } } diff --git a/src/provisioning/dto/create-session-provisioning.dto.ts b/src/provisioning/dto/create-session-provisioning.dto.ts new file mode 100644 index 0000000..2812bd3 --- /dev/null +++ b/src/provisioning/dto/create-session-provisioning.dto.ts @@ -0,0 +1,53 @@ +import { Type } from 'class-transformer'; +import { + IsObject, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; + +import { ProvisioningDeviceDto } from './create-provisioning.dto'; + +export class CreateSessionProvisioningDto { + @IsString() + @IsOptional() + hostname?: string; + + @IsString() + @IsOptional() + os?: string; + + @IsString() + @IsOptional() + arch?: string; + + @IsString() + @IsOptional() + kernel?: string; + + @IsString() + @IsOptional() + distro?: string; + + @IsString() + @IsOptional() + usageType?: string; + + @IsString() + @IsOptional() + sshUser?: string; + + @IsString() + @IsOptional() + workspaceRoot?: string; + + @IsString() + @IsOptional() + publicSshKey?: string; + + @IsObject() + @ValidateNested() + @Type(() => ProvisioningDeviceDto) + @IsOptional() + device?: ProvisioningDeviceDto; +} diff --git a/src/provisioning/provisioning.controller.ts b/src/provisioning/provisioning.controller.ts index c66cea8..d54e154 100644 --- a/src/provisioning/provisioning.controller.ts +++ b/src/provisioning/provisioning.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Headers, Post, UnauthorizedException } from '@nestjs/ import { ConfigService } from '@nestjs/config'; import { CreateProvisioningDto } from './dto/create-provisioning.dto'; +import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto'; import { ProvisioningService } from './provisioning.service'; import type { ProvisioningResponse } from './provisioning.service'; @@ -19,6 +20,17 @@ export class ProvisioningController { return this.provisioningService.createProvisioning(createProvisioningDto); } + @Post('session') + createSessionProvisioning( + @Headers('authorization') authorization: string | undefined, + @Body() createProvisioningDto: CreateSessionProvisioningDto, + ): Promise { + return this.provisioningService.createProvisioningFromAccessToken( + this.getBearerToken(authorization), + createProvisioningDto, + ); + } + @Post('ssh-keys/lookup') listSshKeys( @Headers('x-lms-gateway-token') token: string | undefined, @@ -33,4 +45,9 @@ export class ProvisioningController { } return this.provisioningService.listLauncherSshKeys(body.emails ?? []); } + + private getBearerToken(authorization: string | undefined): string | undefined { + const [type, token] = authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } } diff --git a/src/provisioning/provisioning.service.ts b/src/provisioning/provisioning.service.ts index 3450877..d50e478 100644 --- a/src/provisioning/provisioning.service.ts +++ b/src/provisioning/provisioning.service.ts @@ -10,6 +10,7 @@ import { createHash } from 'crypto'; import { PrismaService } from '../prisma/prisma.service'; import { CreateProvisioningDto } from './dto/create-provisioning.dto'; +import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto'; export type ProvisioningResponse = { installTailscale: boolean; @@ -31,6 +32,8 @@ type AuthLoginResponse = { }; }; +type ProvisioningInput = CreateProvisioningDto | CreateSessionProvisioningDto; + @Injectable() export class ProvisioningService { private readonly authServerUrl: string; @@ -61,6 +64,21 @@ export class ProvisioningService { provisioningDto.account, provisioningDto.password, ); + return this.createProvisioningForUser(provisioningDto, authUser); + } + + async createProvisioningFromAccessToken( + accessToken: string | undefined, + provisioningDto: CreateSessionProvisioningDto, + ): Promise { + const authUser = await this.authenticateAccessToken(accessToken); + return this.createProvisioningForUser(provisioningDto, authUser); + } + + private async createProvisioningForUser( + provisioningDto: ProvisioningInput, + authUser: NonNullable, + ): Promise { const headscaleUser = this.toHeadscaleUserName(authUser.email); const provisioningMode = this.normalizeProvisioningMode( provisioningDto.usageType ?? provisioningDto.device?.usageType, @@ -147,7 +165,7 @@ export class ProvisioningService { } private async recordLauncherSshKey( - provisioningDto: CreateProvisioningDto, + provisioningDto: ProvisioningInput, authUser: NonNullable, ) { const publicKey = this.normalizePublicSshKey( @@ -184,7 +202,7 @@ export class ProvisioningService { } private async recordProvisionedDevice( - provisioningDto: CreateProvisioningDto, + provisioningDto: ProvisioningInput, authUser: NonNullable, tailscaleHostname: string, provisioningMode: 'personal' | 'class', @@ -303,6 +321,42 @@ export class ProvisioningService { return payload.user; } + private async authenticateAccessToken( + accessToken: string | undefined, + ): Promise> { + if (!accessToken) { + throw new UnauthorizedException('Invalid bearer token'); + } + + let response: globalThis.Response; + try { + response = await fetch(`${this.authServerUrl}/auth/me`, { + method: 'GET', + headers: { authorization: `Bearer ${accessToken}` }, + }); + } catch { + throw new BadGatewayException('Auth server is unavailable'); + } + + const payload = (await response.json().catch(() => null)) as + | AuthLoginResponse + | null; + + if (response.status === 401) { + throw new UnauthorizedException('Invalid bearer token'); + } + + if (!response.ok || !payload?.user?.email) { + throw new BadGatewayException({ + message: 'Auth server request failed', + statusCode: response.status, + error: payload, + }); + } + + return payload.user; + } + private async createTailscaleAuthKey( headscaleUser: string, aclTags: string[], @@ -438,7 +492,7 @@ export class ProvisioningService { body: options.body ? JSON.stringify(options.body) : undefined, }); } catch { - throw new BadGatewayException('Headscale server is unavailable'); + throw new BadGatewayException('Server is unavailable'); } } diff --git a/src/terms/terms.controller.ts b/src/terms/terms.controller.ts new file mode 100644 index 0000000..3f22076 --- /dev/null +++ b/src/terms/terms.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { TermsService } from './terms.service'; + +@Controller('api/terms') +export class TermsController { + constructor(private readonly termsService: TermsService) {} + + @Get(':key') + getTerm(@Param('key') key: string) { + return this.termsService.getTerm(key); + } +} diff --git a/src/terms/terms.module.ts b/src/terms/terms.module.ts new file mode 100644 index 0000000..e080e3d --- /dev/null +++ b/src/terms/terms.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { TermsController } from './terms.controller'; +import { TermsService } from './terms.service'; + +@Module({ + imports: [PrismaModule], + controllers: [TermsController], + providers: [TermsService], +}) +export class TermsModule {} diff --git a/src/terms/terms.service.ts b/src/terms/terms.service.ts new file mode 100644 index 0000000..0422c15 --- /dev/null +++ b/src/terms/terms.service.ts @@ -0,0 +1,25 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class TermsService { + constructor(private readonly prisma: PrismaService) {} + + async getTerm(key: string) { + const term = await this.prisma.launcherTerm.findUnique({ + where: { key }, + select: { + key: true, + body: true, + isRequired: true, + updatedAt: true, + }, + }); + + if (!term) { + throw new NotFoundException('약관을 찾을 수 없습니다.'); + } + + return { term }; + } +}