diff --git a/prisma/migrations/20260709010000_add_admin_app_permissions/migration.sql b/prisma/migrations/20260709010000_add_admin_app_permissions/migration.sql new file mode 100644 index 0000000..7f9f16d --- /dev/null +++ b/prisma/migrations/20260709010000_add_admin_app_permissions/migration.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS "admin_app_permissions" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "admin_id" UUID NOT NULL, + "app_id" UUID NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "admin_app_permissions_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "admin_app_permissions_admin_id_app_id_key" + ON "admin_app_permissions"("admin_id", "app_id"); + +CREATE INDEX IF NOT EXISTS "admin_app_permissions_app_id_idx" + ON "admin_app_permissions"("app_id"); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'admin_app_permissions_admin_id_fkey' + ) THEN + ALTER TABLE "admin_app_permissions" + ADD CONSTRAINT "admin_app_permissions_admin_id_fkey" + FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'admin_app_permissions_app_id_fkey' + ) THEN + ALTER TABLE "admin_app_permissions" + ADD CONSTRAINT "admin_app_permissions_app_id_fkey" + FOREIGN KEY ("app_id") REFERENCES "apps"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a568824..bda4915 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -74,11 +74,25 @@ model Admin { sessions AdminSession[] auditLogs AdminAuditLog[] sshKeys AdminSshKey[] + appPermissions AdminAppPermission[] @@index([isActive, role]) @@map("admins") } +model AdminAppPermission { + id String @id @default(uuid()) @db.Uuid + adminId String @map("admin_id") @db.Uuid + appId String @map("app_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + admin Admin @relation(fields: [adminId], references: [id], onDelete: Cascade) + app App @relation(fields: [appId], references: [id], onDelete: Cascade) + + @@unique([adminId, appId]) + @@index([appId]) + @@map("admin_app_permissions") +} + model AdminSshKey { id String @id @default(uuid()) @db.Uuid adminId String @map("admin_id") @db.Uuid @@ -142,6 +156,7 @@ model App { releases AppRelease[] deployments DeploymentJob[] runtimeProfiles RuntimeEnvProfile[] + adminPermissions AdminAppPermission[] @@map("apps") } diff --git a/src/admin-auth/admin-auth.service.ts b/src/admin-auth/admin-auth.service.ts index b246bcc..4ed6de4 100644 --- a/src/admin-auth/admin-auth.service.ts +++ b/src/admin-auth/admin-auth.service.ts @@ -22,6 +22,9 @@ export class AdminAuthService { role: true, isActive: true, passwordHash: true, + appPermissions: { + select: { appId: true }, + }, }, }); @@ -57,6 +60,7 @@ export class AdminAuthService { email: admin.email, name: admin.name, role: admin.role, + appPermissions: admin.appPermissions.map((permission) => permission.appId), }, }; } diff --git a/src/admins/admins.controller.ts b/src/admins/admins.controller.ts index 7752113..ae47162 100644 --- a/src/admins/admins.controller.ts +++ b/src/admins/admins.controller.ts @@ -1,9 +1,10 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common'; import { CreateAdminSshKeyDto, CreateAdminDto, ResetAdminPasswordDto, + UpdateAdminAppPermissionsDto, UpdateAdminRoleDto, UpdateAdminStatusDto, } from './dto'; @@ -46,6 +47,16 @@ export class AdminsController { return this.adminsService.resetPassword(id, body); } + @Get(':id/app-permissions') + listAppPermissions(@Param('id') id: string) { + return this.adminsService.listAppPermissions(id); + } + + @Put(':id/app-permissions') + updateAppPermissions(@Param('id') id: string, @Body() body: UpdateAdminAppPermissionsDto) { + return this.adminsService.updateAppPermissions(id, body); + } + @Post(':id/ssh-keys') createSshKey(@Param('id') id: string, @Body() body: CreateAdminSshKeyDto) { return this.adminsService.createSshKey(id, body); diff --git a/src/admins/admins.service.ts b/src/admins/admins.service.ts index 6389845..2fb6287 100644 --- a/src/admins/admins.service.ts +++ b/src/admins/admins.service.ts @@ -9,6 +9,7 @@ import { CreateAdminSshKeyDto, CreateAdminDto, ResetAdminPasswordDto, + UpdateAdminAppPermissionsDto, UpdateAdminRoleDto, UpdateAdminStatusDto, } from './dto'; @@ -63,6 +64,9 @@ export class AdminsService { orderBy: { createdAt: 'desc' }, select: adminSshKeySelect, }, + appPermissions: { + select: { appId: true }, + }, }, }); } @@ -132,6 +136,55 @@ export class AdminsService { return { admin }; } + async listAppPermissions(id: string) { + await this.ensureExists(id); + + const permissions = await this.prisma.adminAppPermission.findMany({ + where: { adminId: id }, + orderBy: { createdAt: 'asc' }, + select: { appId: true }, + }); + + return { appIds: permissions.map((permission) => permission.appId) }; + } + + async updateAppPermissions(id: string, body: UpdateAdminAppPermissionsDto) { + const admin = await this.prisma.admin.findUnique({ + where: { id }, + select: { id: true, role: true }, + }); + + if (!admin) { + throw new NotFoundException('관리자를 찾을 수 없습니다.'); + } + + if (admin.role === AdminRole.OWNER) { + throw new BadRequestException('오너 권한은 앱 권한을 개별 설정하지 않습니다.'); + } + + const appIds = [...new Set(body.appIds.filter(Boolean))]; + const existingApps = await this.prisma.app.findMany({ + where: { id: { in: appIds } }, + select: { id: true }, + }); + const existingAppIds = new Set(existingApps.map((app) => app.id)); + const validAppIds = appIds.filter((appId) => existingAppIds.has(appId)); + + await this.prisma.$transaction([ + this.prisma.adminAppPermission.deleteMany({ where: { adminId: id } }), + ...(validAppIds.length + ? [ + this.prisma.adminAppPermission.createMany({ + data: validAppIds.map((appId) => ({ adminId: id, appId })), + skipDuplicates: true, + }), + ] + : []), + ]); + + return { appIds: validAppIds }; + } + async createSshKey(adminId: string, body: CreateAdminSshKeyDto) { await this.ensureExists(adminId); diff --git a/src/admins/dto.ts b/src/admins/dto.ts index 048adbe..3bc939f 100644 --- a/src/admins/dto.ts +++ b/src/admins/dto.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsEmail, IsEnum, IsOptional, IsString, MinLength } from 'class-validator'; +import { IsArray, IsBoolean, IsEmail, IsEnum, IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; import { AdminRole } from './admin-role'; export class CreateAdminDto { @@ -44,3 +44,9 @@ export class CreateAdminSshKeyDto { @IsString() giteaUsername?: string; } + +export class UpdateAdminAppPermissionsDto { + @IsArray() + @IsUUID('4', { each: true }) + appIds!: string[]; +}