Compare commits
1 Commits
feat/ms22-
...
feat/ms22-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d66e00710 |
@@ -53,8 +53,6 @@ import { ConversationArchiveModule } from "./conversation-archive/conversation-a
|
|||||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||||
import { AgentConfigModule } from "./agent-config/agent-config.module";
|
import { AgentConfigModule } from "./agent-config/agent-config.module";
|
||||||
import { ContainerLifecycleModule } from "./container-lifecycle/container-lifecycle.module";
|
import { ContainerLifecycleModule } from "./container-lifecycle/container-lifecycle.module";
|
||||||
import { FleetSettingsModule } from "./fleet-settings/fleet-settings.module";
|
|
||||||
import { OnboardingModule } from "./onboarding/onboarding.module";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -129,8 +127,6 @@ import { OnboardingModule } from "./onboarding/onboarding.module";
|
|||||||
ConversationArchiveModule,
|
ConversationArchiveModule,
|
||||||
AgentConfigModule,
|
AgentConfigModule,
|
||||||
ContainerLifecycleModule,
|
ContainerLifecycleModule,
|
||||||
FleetSettingsModule,
|
|
||||||
OnboardingModule,
|
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Param,
|
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
Put,
|
|
||||||
UseGuards,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import type { AuthUser } from "@mosaic/shared";
|
|
||||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
|
||||||
import { AdminGuard } from "../auth/guards/admin.guard";
|
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
|
||||||
import type {
|
|
||||||
CreateProviderDto,
|
|
||||||
ResetPasswordDto,
|
|
||||||
UpdateAgentConfigDto,
|
|
||||||
UpdateOidcDto,
|
|
||||||
UpdateProviderDto,
|
|
||||||
} from "./fleet-settings.dto";
|
|
||||||
import { FleetSettingsService } from "./fleet-settings.service";
|
|
||||||
|
|
||||||
@Controller("fleet-settings")
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
export class FleetSettingsController {
|
|
||||||
constructor(private readonly fleetSettingsService: FleetSettingsService) {}
|
|
||||||
|
|
||||||
// --- Provider endpoints (user-scoped) ---
|
|
||||||
// GET /api/fleet-settings/providers — list user's providers
|
|
||||||
@Get("providers")
|
|
||||||
async listProviders(@CurrentUser() user: AuthUser) {
|
|
||||||
return this.fleetSettingsService.listProviders(user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/fleet-settings/providers/:id — get single provider
|
|
||||||
@Get("providers/:id")
|
|
||||||
async getProvider(@CurrentUser() user: AuthUser, @Param("id") id: string) {
|
|
||||||
return this.fleetSettingsService.getProvider(user.id, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/fleet-settings/providers — create provider
|
|
||||||
@Post("providers")
|
|
||||||
async createProvider(@CurrentUser() user: AuthUser, @Body() dto: CreateProviderDto) {
|
|
||||||
return this.fleetSettingsService.createProvider(user.id, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH /api/fleet-settings/providers/:id — update provider
|
|
||||||
@Patch("providers/:id")
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async updateProvider(
|
|
||||||
@CurrentUser() user: AuthUser,
|
|
||||||
@Param("id") id: string,
|
|
||||||
@Body() dto: UpdateProviderDto
|
|
||||||
) {
|
|
||||||
await this.fleetSettingsService.updateProvider(user.id, id, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/fleet-settings/providers/:id — delete provider
|
|
||||||
@Delete("providers/:id")
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async deleteProvider(@CurrentUser() user: AuthUser, @Param("id") id: string) {
|
|
||||||
await this.fleetSettingsService.deleteProvider(user.id, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Agent config endpoints (user-scoped) ---
|
|
||||||
// GET /api/fleet-settings/agent-config — get user's agent config
|
|
||||||
@Get("agent-config")
|
|
||||||
async getAgentConfig(@CurrentUser() user: AuthUser) {
|
|
||||||
return this.fleetSettingsService.getAgentConfig(user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH /api/fleet-settings/agent-config — update user's agent config
|
|
||||||
@Patch("agent-config")
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async updateAgentConfig(@CurrentUser() user: AuthUser, @Body() dto: UpdateAgentConfigDto) {
|
|
||||||
await this.fleetSettingsService.updateAgentConfig(user.id, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- OIDC endpoints (admin only — use AdminGuard) ---
|
|
||||||
// GET /api/fleet-settings/oidc — get OIDC config
|
|
||||||
@Get("oidc")
|
|
||||||
@UseGuards(AdminGuard)
|
|
||||||
async getOidcConfig() {
|
|
||||||
return this.fleetSettingsService.getOidcConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUT /api/fleet-settings/oidc — update OIDC config
|
|
||||||
@Put("oidc")
|
|
||||||
@UseGuards(AdminGuard)
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async updateOidcConfig(@Body() dto: UpdateOidcDto) {
|
|
||||||
await this.fleetSettingsService.updateOidcConfig(dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/fleet-settings/oidc — remove OIDC config
|
|
||||||
@Delete("oidc")
|
|
||||||
@UseGuards(AdminGuard)
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async deleteOidcConfig() {
|
|
||||||
await this.fleetSettingsService.deleteOidcConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Breakglass endpoints (admin only) ---
|
|
||||||
// POST /api/fleet-settings/breakglass/reset-password — reset admin password
|
|
||||||
@Post("breakglass/reset-password")
|
|
||||||
@UseGuards(AdminGuard)
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async resetBreakglassPassword(@Body() dto: ResetPasswordDto) {
|
|
||||||
await this.fleetSettingsService.resetBreakglassPassword(dto.username, dto.newPassword);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import {
|
|
||||||
ArrayNotEmpty,
|
|
||||||
IsArray,
|
|
||||||
IsBoolean,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsObject,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
IsUrl,
|
|
||||||
MaxLength,
|
|
||||||
MinLength,
|
|
||||||
} from "class-validator";
|
|
||||||
|
|
||||||
export class CreateProviderDto {
|
|
||||||
@IsString({ message: "name must be a string" })
|
|
||||||
@IsNotEmpty({ message: "name is required" })
|
|
||||||
@MaxLength(100, { message: "name must not exceed 100 characters" })
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsString({ message: "displayName must be a string" })
|
|
||||||
@IsNotEmpty({ message: "displayName is required" })
|
|
||||||
@MaxLength(255, { message: "displayName must not exceed 255 characters" })
|
|
||||||
displayName!: string;
|
|
||||||
|
|
||||||
@IsString({ message: "type must be a string" })
|
|
||||||
@IsNotEmpty({ message: "type is required" })
|
|
||||||
@MaxLength(100, { message: "type must not exceed 100 characters" })
|
|
||||||
type!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUrl(
|
|
||||||
{ require_tld: false },
|
|
||||||
{ message: "baseUrl must be a valid URL (for example: https://api.example.com/v1)" }
|
|
||||||
)
|
|
||||||
baseUrl?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString({ message: "apiKey must be a string" })
|
|
||||||
apiKey?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString({ message: "apiType must be a string" })
|
|
||||||
@MaxLength(100, { message: "apiType must not exceed 100 characters" })
|
|
||||||
apiType?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray({ message: "models must be an array" })
|
|
||||||
@IsObject({ each: true, message: "each model must be an object" })
|
|
||||||
models?: Record<string, unknown>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateProviderDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString({ message: "displayName must be a string" })
|
|
||||||
@MaxLength(255, { message: "displayName must not exceed 255 characters" })
|
|
||||||
displayName?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUrl(
|
|
||||||
{ require_tld: false },
|
|
||||||
{ message: "baseUrl must be a valid URL (for example: https://api.example.com/v1)" }
|
|
||||||
)
|
|
||||||
baseUrl?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString({ message: "apiKey must be a string" })
|
|
||||||
apiKey?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean({ message: "isActive must be a boolean" })
|
|
||||||
isActive?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray({ message: "models must be an array" })
|
|
||||||
@IsObject({ each: true, message: "each model must be an object" })
|
|
||||||
models?: Record<string, unknown>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateAgentConfigDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString({ message: "primaryModel must be a string" })
|
|
||||||
@MaxLength(255, { message: "primaryModel must not exceed 255 characters" })
|
|
||||||
primaryModel?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray({ message: "fallbackModels must be an array" })
|
|
||||||
@ArrayNotEmpty({ message: "fallbackModels cannot be empty" })
|
|
||||||
@IsString({ each: true, message: "each fallback model must be a string" })
|
|
||||||
fallbackModels?: string[];
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString({ message: "personality must be a string" })
|
|
||||||
personality?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateOidcDto {
|
|
||||||
@IsString({ message: "issuerUrl must be a string" })
|
|
||||||
@IsNotEmpty({ message: "issuerUrl is required" })
|
|
||||||
@IsUrl(
|
|
||||||
{ require_tld: false },
|
|
||||||
{ message: "issuerUrl must be a valid URL (for example: https://issuer.example.com)" }
|
|
||||||
)
|
|
||||||
issuerUrl!: string;
|
|
||||||
|
|
||||||
@IsString({ message: "clientId must be a string" })
|
|
||||||
@IsNotEmpty({ message: "clientId is required" })
|
|
||||||
clientId!: string;
|
|
||||||
|
|
||||||
@IsString({ message: "clientSecret must be a string" })
|
|
||||||
@IsNotEmpty({ message: "clientSecret is required" })
|
|
||||||
clientSecret!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ResetPasswordDto {
|
|
||||||
@IsString({ message: "username must be a string" })
|
|
||||||
@IsNotEmpty({ message: "username is required" })
|
|
||||||
username!: string;
|
|
||||||
|
|
||||||
@IsString({ message: "newPassword must be a string" })
|
|
||||||
@MinLength(8, { message: "newPassword must be at least 8 characters" })
|
|
||||||
newPassword!: string;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
|
||||||
import { CryptoModule } from "../crypto/crypto.module";
|
|
||||||
import { FleetSettingsController } from "./fleet-settings.controller";
|
|
||||||
import { FleetSettingsService } from "./fleet-settings.service";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule, CryptoModule],
|
|
||||||
controllers: [FleetSettingsController],
|
|
||||||
providers: [FleetSettingsService],
|
|
||||||
exports: [FleetSettingsService],
|
|
||||||
})
|
|
||||||
export class FleetSettingsModule {}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { NotFoundException } from "@nestjs/common";
|
|
||||||
import { compare } from "bcryptjs";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { FleetSettingsService } from "./fleet-settings.service";
|
|
||||||
import type { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import type { CryptoService } from "../crypto/crypto.service";
|
|
||||||
|
|
||||||
describe("FleetSettingsService", () => {
|
|
||||||
let service: FleetSettingsService;
|
|
||||||
|
|
||||||
const mockPrisma = {
|
|
||||||
llmProvider: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
},
|
|
||||||
userAgentConfig: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
upsert: vi.fn(),
|
|
||||||
},
|
|
||||||
systemConfig: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
upsert: vi.fn(),
|
|
||||||
deleteMany: vi.fn(),
|
|
||||||
},
|
|
||||||
breakglassUser: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockCrypto = {
|
|
||||||
encrypt: vi.fn((value: string) => `enc:${value}`),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
service = new FleetSettingsService(
|
|
||||||
mockPrisma as unknown as PrismaService,
|
|
||||||
mockCrypto as unknown as CryptoService
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("listProviders returns only providers for the given userId", async () => {
|
|
||||||
mockPrisma.llmProvider.findMany.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: "prov-1",
|
|
||||||
name: "openai-main",
|
|
||||||
displayName: "OpenAI",
|
|
||||||
type: "openai",
|
|
||||||
baseUrl: "https://api.openai.com/v1",
|
|
||||||
isActive: true,
|
|
||||||
models: [{ id: "gpt-4.1" }],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await service.listProviders("user-1");
|
|
||||||
|
|
||||||
expect(mockPrisma.llmProvider.findMany).toHaveBeenCalledWith({
|
|
||||||
where: { userId: "user-1" },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
displayName: true,
|
|
||||||
type: true,
|
|
||||||
baseUrl: true,
|
|
||||||
isActive: true,
|
|
||||||
models: true,
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
});
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
id: "prov-1",
|
|
||||||
name: "openai-main",
|
|
||||||
displayName: "OpenAI",
|
|
||||||
type: "openai",
|
|
||||||
baseUrl: "https://api.openai.com/v1",
|
|
||||||
isActive: true,
|
|
||||||
models: [{ id: "gpt-4.1" }],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createProvider encrypts apiKey", async () => {
|
|
||||||
mockPrisma.llmProvider.create.mockResolvedValue({
|
|
||||||
id: "prov-2",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.createProvider("user-1", {
|
|
||||||
name: "zai-main",
|
|
||||||
displayName: "Z.ai",
|
|
||||||
type: "zai",
|
|
||||||
apiKey: "plaintext-key",
|
|
||||||
models: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockCrypto.encrypt).toHaveBeenCalledWith("plaintext-key");
|
|
||||||
expect(mockPrisma.llmProvider.create).toHaveBeenCalledWith({
|
|
||||||
data: {
|
|
||||||
userId: "user-1",
|
|
||||||
name: "zai-main",
|
|
||||||
displayName: "Z.ai",
|
|
||||||
type: "zai",
|
|
||||||
baseUrl: null,
|
|
||||||
apiKey: "enc:plaintext-key",
|
|
||||||
apiType: "openai-completions",
|
|
||||||
models: [],
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ id: "prov-2" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updateProvider rejects if not owned by user", async () => {
|
|
||||||
mockPrisma.llmProvider.findFirst.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.updateProvider("user-1", "provider-1", {
|
|
||||||
displayName: "New Name",
|
|
||||||
})
|
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
|
|
||||||
expect(mockPrisma.llmProvider.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteProvider rejects if not owned by user", async () => {
|
|
||||||
mockPrisma.llmProvider.findFirst.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.deleteProvider("user-1", "provider-1")).rejects.toBeInstanceOf(
|
|
||||||
NotFoundException
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockPrisma.llmProvider.delete).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getOidcConfig never returns clientSecret", async () => {
|
|
||||||
mockPrisma.systemConfig.findMany.mockResolvedValue([
|
|
||||||
{
|
|
||||||
key: "oidc.issuerUrl",
|
|
||||||
value: "https://issuer.example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "oidc.clientId",
|
|
||||||
value: "client-id-1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "oidc.clientSecret",
|
|
||||||
value: "enc:very-secret",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await service.getOidcConfig();
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
issuerUrl: "https://issuer.example.com",
|
|
||||||
clientId: "client-id-1",
|
|
||||||
configured: true,
|
|
||||||
});
|
|
||||||
expect(result).not.toHaveProperty("clientSecret");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updateOidcConfig encrypts clientSecret", async () => {
|
|
||||||
await service.updateOidcConfig({
|
|
||||||
issuerUrl: "https://issuer.example.com",
|
|
||||||
clientId: "client-id-1",
|
|
||||||
clientSecret: "super-secret",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockCrypto.encrypt).toHaveBeenCalledWith("super-secret");
|
|
||||||
expect(mockPrisma.systemConfig.upsert).toHaveBeenCalledTimes(3);
|
|
||||||
expect(mockPrisma.systemConfig.upsert).toHaveBeenCalledWith({
|
|
||||||
where: { key: "oidc.clientSecret" },
|
|
||||||
update: { value: "enc:super-secret", encrypted: true },
|
|
||||||
create: { key: "oidc.clientSecret", value: "enc:super-secret", encrypted: true },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resetBreakglassPassword hashes new password", async () => {
|
|
||||||
mockPrisma.breakglassUser.findUnique.mockResolvedValue({
|
|
||||||
id: "bg-1",
|
|
||||||
username: "admin",
|
|
||||||
passwordHash: "old-hash",
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.resetBreakglassPassword("admin", "new-password-123");
|
|
||||||
|
|
||||||
expect(mockPrisma.breakglassUser.update).toHaveBeenCalledOnce();
|
|
||||||
const updateCall = mockPrisma.breakglassUser.update.mock.calls[0]?.[0];
|
|
||||||
const newHash = updateCall?.data?.passwordHash;
|
|
||||||
expect(newHash).toBeTypeOf("string");
|
|
||||||
expect(newHash).not.toBe("new-password-123");
|
|
||||||
expect(await compare("new-password-123", newHash as string)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
|
||||||
import { hash } from "bcryptjs";
|
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import { CryptoService } from "../crypto/crypto.service";
|
|
||||||
import type {
|
|
||||||
CreateProviderDto,
|
|
||||||
ResetPasswordDto,
|
|
||||||
UpdateAgentConfigDto,
|
|
||||||
UpdateOidcDto,
|
|
||||||
UpdateProviderDto,
|
|
||||||
} from "./fleet-settings.dto";
|
|
||||||
|
|
||||||
const BCRYPT_ROUNDS = 12;
|
|
||||||
const DEFAULT_PROVIDER_API_TYPE = "openai-completions";
|
|
||||||
const OIDC_ISSUER_KEY = "oidc.issuerUrl";
|
|
||||||
const OIDC_CLIENT_ID_KEY = "oidc.clientId";
|
|
||||||
const OIDC_CLIENT_SECRET_KEY = "oidc.clientSecret";
|
|
||||||
const OIDC_KEYS = [OIDC_ISSUER_KEY, OIDC_CLIENT_ID_KEY, OIDC_CLIENT_SECRET_KEY] as const;
|
|
||||||
|
|
||||||
export interface FleetProviderResponse {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
type: string;
|
|
||||||
baseUrl: string | null;
|
|
||||||
isActive: boolean;
|
|
||||||
models: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FleetAgentConfigResponse {
|
|
||||||
primaryModel: string | null;
|
|
||||||
fallbackModels: unknown[];
|
|
||||||
personality: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OidcConfigResponse {
|
|
||||||
issuerUrl?: string;
|
|
||||||
clientId?: string;
|
|
||||||
configured: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FleetSettingsService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly crypto: CryptoService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// --- LLM Provider CRUD (per-user scoped) ---
|
|
||||||
|
|
||||||
async listProviders(userId: string): Promise<FleetProviderResponse[]> {
|
|
||||||
return this.prisma.llmProvider.findMany({
|
|
||||||
where: { userId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
displayName: true,
|
|
||||||
type: true,
|
|
||||||
baseUrl: true,
|
|
||||||
isActive: true,
|
|
||||||
models: true,
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProvider(userId: string, providerId: string): Promise<FleetProviderResponse> {
|
|
||||||
const provider = await this.prisma.llmProvider.findFirst({
|
|
||||||
where: {
|
|
||||||
id: providerId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
displayName: true,
|
|
||||||
type: true,
|
|
||||||
baseUrl: true,
|
|
||||||
isActive: true,
|
|
||||||
models: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!provider) {
|
|
||||||
throw new NotFoundException(`Provider ${providerId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return provider;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createProvider(userId: string, data: CreateProviderDto): Promise<{ id: string }> {
|
|
||||||
const provider = await this.prisma.llmProvider.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
name: data.name,
|
|
||||||
displayName: data.displayName,
|
|
||||||
type: data.type,
|
|
||||||
baseUrl: data.baseUrl ?? null,
|
|
||||||
apiKey: data.apiKey ? this.crypto.encrypt(data.apiKey) : null,
|
|
||||||
apiType: data.apiType ?? DEFAULT_PROVIDER_API_TYPE,
|
|
||||||
models: (data.models ?? []) as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return provider;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateProvider(userId: string, providerId: string, data: UpdateProviderDto): Promise<void> {
|
|
||||||
await this.assertProviderOwnership(userId, providerId);
|
|
||||||
|
|
||||||
const updateData: Prisma.LlmProviderUpdateInput = {};
|
|
||||||
if (data.displayName !== undefined) {
|
|
||||||
updateData.displayName = data.displayName;
|
|
||||||
}
|
|
||||||
if (data.baseUrl !== undefined) {
|
|
||||||
updateData.baseUrl = data.baseUrl;
|
|
||||||
}
|
|
||||||
if (data.isActive !== undefined) {
|
|
||||||
updateData.isActive = data.isActive;
|
|
||||||
}
|
|
||||||
if (data.models !== undefined) {
|
|
||||||
updateData.models = data.models as Prisma.InputJsonValue;
|
|
||||||
}
|
|
||||||
if (data.apiKey !== undefined) {
|
|
||||||
updateData.apiKey = data.apiKey.length > 0 ? this.crypto.encrypt(data.apiKey) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.llmProvider.update({
|
|
||||||
where: { id: providerId },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteProvider(userId: string, providerId: string): Promise<void> {
|
|
||||||
await this.assertProviderOwnership(userId, providerId);
|
|
||||||
|
|
||||||
await this.prisma.llmProvider.delete({
|
|
||||||
where: { id: providerId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- User Agent Config ---
|
|
||||||
|
|
||||||
async getAgentConfig(userId: string): Promise<FleetAgentConfigResponse> {
|
|
||||||
const config = await this.prisma.userAgentConfig.findUnique({
|
|
||||||
where: { userId },
|
|
||||||
select: {
|
|
||||||
primaryModel: true,
|
|
||||||
fallbackModels: true,
|
|
||||||
personality: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!config) {
|
|
||||||
return {
|
|
||||||
primaryModel: null,
|
|
||||||
fallbackModels: [],
|
|
||||||
personality: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
primaryModel: config.primaryModel,
|
|
||||||
fallbackModels: this.normalizeJsonArray(config.fallbackModels),
|
|
||||||
personality: config.personality,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateAgentConfig(userId: string, data: UpdateAgentConfigDto): Promise<void> {
|
|
||||||
const updateData: Prisma.UserAgentConfigUpdateInput = {};
|
|
||||||
if (data.primaryModel !== undefined) {
|
|
||||||
updateData.primaryModel = data.primaryModel;
|
|
||||||
}
|
|
||||||
if (data.personality !== undefined) {
|
|
||||||
updateData.personality = data.personality;
|
|
||||||
}
|
|
||||||
if (data.fallbackModels !== undefined) {
|
|
||||||
updateData.fallbackModels = data.fallbackModels as Prisma.InputJsonValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createData: Prisma.UserAgentConfigCreateInput = {
|
|
||||||
userId,
|
|
||||||
fallbackModels: (data.fallbackModels ?? []) as Prisma.InputJsonValue,
|
|
||||||
...(data.primaryModel !== undefined ? { primaryModel: data.primaryModel } : {}),
|
|
||||||
...(data.personality !== undefined ? { personality: data.personality } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.prisma.userAgentConfig.upsert({
|
|
||||||
where: { userId },
|
|
||||||
create: createData,
|
|
||||||
update: updateData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- OIDC Config (admin only) ---
|
|
||||||
|
|
||||||
async getOidcConfig(): Promise<OidcConfigResponse> {
|
|
||||||
const entries = await this.prisma.systemConfig.findMany({
|
|
||||||
where: {
|
|
||||||
key: {
|
|
||||||
in: [...OIDC_KEYS],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
key: true,
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const byKey = new Map(entries.map((entry) => [entry.key, entry.value]));
|
|
||||||
const issuerUrl = byKey.get(OIDC_ISSUER_KEY);
|
|
||||||
const clientId = byKey.get(OIDC_CLIENT_ID_KEY);
|
|
||||||
const hasSecret = byKey.has(OIDC_CLIENT_SECRET_KEY);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...(issuerUrl ? { issuerUrl } : {}),
|
|
||||||
...(clientId ? { clientId } : {}),
|
|
||||||
configured: Boolean(issuerUrl && clientId && hasSecret),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateOidcConfig(data: UpdateOidcDto): Promise<void> {
|
|
||||||
const encryptedSecret = this.crypto.encrypt(data.clientSecret);
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
this.upsertSystemConfig(OIDC_ISSUER_KEY, data.issuerUrl, false),
|
|
||||||
this.upsertSystemConfig(OIDC_CLIENT_ID_KEY, data.clientId, false),
|
|
||||||
this.upsertSystemConfig(OIDC_CLIENT_SECRET_KEY, encryptedSecret, true),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteOidcConfig(): Promise<void> {
|
|
||||||
await this.prisma.systemConfig.deleteMany({
|
|
||||||
where: {
|
|
||||||
key: {
|
|
||||||
in: [...OIDC_KEYS],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Breakglass (admin only) ---
|
|
||||||
|
|
||||||
async resetBreakglassPassword(
|
|
||||||
username: ResetPasswordDto["username"],
|
|
||||||
newPassword: ResetPasswordDto["newPassword"]
|
|
||||||
): Promise<void> {
|
|
||||||
const user = await this.prisma.breakglassUser.findUnique({
|
|
||||||
where: { username },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException(`Breakglass user ${username} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await hash(newPassword, BCRYPT_ROUNDS);
|
|
||||||
|
|
||||||
await this.prisma.breakglassUser.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: { passwordHash },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertProviderOwnership(userId: string, providerId: string): Promise<void> {
|
|
||||||
const provider = await this.prisma.llmProvider.findFirst({
|
|
||||||
where: {
|
|
||||||
id: providerId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!provider) {
|
|
||||||
throw new NotFoundException(`Provider ${providerId} not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async upsertSystemConfig(key: string, value: string, encrypted: boolean): Promise<void> {
|
|
||||||
await this.prisma.systemConfig.upsert({
|
|
||||||
where: { key },
|
|
||||||
update: { value, encrypted },
|
|
||||||
create: { key, value, encrypted },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeJsonArray(value: unknown): unknown[] {
|
|
||||||
return Array.isArray(value) ? value : [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { Body, Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from "@nestjs/common";
|
|
||||||
import {
|
|
||||||
AddProviderDto,
|
|
||||||
ConfigureOidcDto,
|
|
||||||
CreateBreakglassDto,
|
|
||||||
TestProviderDto,
|
|
||||||
} from "./onboarding.dto";
|
|
||||||
import { OnboardingGuard } from "./onboarding.guard";
|
|
||||||
import { OnboardingService } from "./onboarding.service";
|
|
||||||
|
|
||||||
@Controller("onboarding")
|
|
||||||
export class OnboardingController {
|
|
||||||
constructor(private readonly onboardingService: OnboardingService) {}
|
|
||||||
|
|
||||||
// GET /api/onboarding/status — returns { completed: boolean }
|
|
||||||
@Get("status")
|
|
||||||
async status(): Promise<{ completed: boolean }> {
|
|
||||||
return {
|
|
||||||
completed: await this.onboardingService.isCompleted(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/onboarding/breakglass — body: { username, password } → create admin
|
|
||||||
@Post("breakglass")
|
|
||||||
@UseGuards(OnboardingGuard)
|
|
||||||
async createBreakglass(
|
|
||||||
@Body() body: CreateBreakglassDto
|
|
||||||
): Promise<{ id: string; username: string }> {
|
|
||||||
return this.onboardingService.createBreakglassUser(body.username, body.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/onboarding/oidc — body: { issuerUrl, clientId, clientSecret } → save OIDC
|
|
||||||
@Post("oidc")
|
|
||||||
@UseGuards(OnboardingGuard)
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async configureOidc(@Body() body: ConfigureOidcDto): Promise<void> {
|
|
||||||
await this.onboardingService.configureOidc(body.issuerUrl, body.clientId, body.clientSecret);
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/onboarding/provider — body: { name, displayName, type, baseUrl?, apiKey?, models? } → add provider
|
|
||||||
@Post("provider")
|
|
||||||
@UseGuards(OnboardingGuard)
|
|
||||||
async addProvider(@Body() body: AddProviderDto): Promise<{ id: string }> {
|
|
||||||
const userId = await this.onboardingService.getBreakglassUserId();
|
|
||||||
|
|
||||||
return this.onboardingService.addProvider(userId, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/onboarding/provider/test — body: { type, baseUrl?, apiKey? } → test connection
|
|
||||||
@Post("provider/test")
|
|
||||||
@UseGuards(OnboardingGuard)
|
|
||||||
async testProvider(@Body() body: TestProviderDto): Promise<{ success: boolean; error?: string }> {
|
|
||||||
return this.onboardingService.testProvider(body.type, body.baseUrl, body.apiKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/onboarding/complete — mark done
|
|
||||||
@Post("complete")
|
|
||||||
@UseGuards(OnboardingGuard)
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async complete(): Promise<void> {
|
|
||||||
await this.onboardingService.complete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { Type } from "class-transformer";
|
|
||||||
import { IsArray, IsOptional, IsString, IsUrl, MinLength, ValidateNested } from "class-validator";
|
|
||||||
|
|
||||||
export class CreateBreakglassDto {
|
|
||||||
@IsString()
|
|
||||||
@MinLength(3)
|
|
||||||
username!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MinLength(8)
|
|
||||||
password!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ConfigureOidcDto {
|
|
||||||
@IsString()
|
|
||||||
@IsUrl({ require_tld: false })
|
|
||||||
issuerUrl!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
clientId!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
clientSecret!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ProviderModelDto {
|
|
||||||
@IsString()
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AddProviderDto {
|
|
||||||
@IsString()
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
displayName!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
type!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
baseUrl?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
apiKey?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@ValidateNested({ each: true })
|
|
||||||
@Type(() => ProviderModelDto)
|
|
||||||
models?: ProviderModelDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TestProviderDto {
|
|
||||||
@IsString()
|
|
||||||
type!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
baseUrl?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
apiKey?: string;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
|
||||||
import { OnboardingService } from "./onboarding.service";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OnboardingGuard implements CanActivate {
|
|
||||||
constructor(private readonly onboardingService: OnboardingService) {}
|
|
||||||
|
|
||||||
async canActivate(_context: ExecutionContext): Promise<boolean> {
|
|
||||||
const completed = await this.onboardingService.isCompleted();
|
|
||||||
|
|
||||||
if (completed) {
|
|
||||||
throw new ForbiddenException("Onboarding already completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { ConfigModule } from "@nestjs/config";
|
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
|
||||||
import { CryptoModule } from "../crypto/crypto.module";
|
|
||||||
import { OnboardingController } from "./onboarding.controller";
|
|
||||||
import { OnboardingService } from "./onboarding.service";
|
|
||||||
import { OnboardingGuard } from "./onboarding.guard";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule, CryptoModule, ConfigModule],
|
|
||||||
controllers: [OnboardingController],
|
|
||||||
providers: [OnboardingService, OnboardingGuard],
|
|
||||||
exports: [OnboardingService],
|
|
||||||
})
|
|
||||||
export class OnboardingModule {}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { hash } from "bcryptjs";
|
|
||||||
import { OnboardingService } from "./onboarding.service";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import { CryptoService } from "../crypto/crypto.service";
|
|
||||||
|
|
||||||
vi.mock("bcryptjs", () => ({
|
|
||||||
hash: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("OnboardingService", () => {
|
|
||||||
let service: OnboardingService;
|
|
||||||
|
|
||||||
const mockPrismaService = {
|
|
||||||
systemConfig: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
upsert: vi.fn(),
|
|
||||||
},
|
|
||||||
breakglassUser: {
|
|
||||||
count: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
},
|
|
||||||
llmProvider: {
|
|
||||||
create: vi.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockCryptoService = {
|
|
||||||
encrypt: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
service = new OnboardingService(
|
|
||||||
mockPrismaService as unknown as PrismaService,
|
|
||||||
mockCryptoService as unknown as CryptoService
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isCompleted returns false when no config exists", async () => {
|
|
||||||
mockPrismaService.systemConfig.findUnique.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.isCompleted()).resolves.toBe(false);
|
|
||||||
expect(mockPrismaService.systemConfig.findUnique).toHaveBeenCalledWith({
|
|
||||||
where: { key: "onboarding.completed" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isCompleted returns true when completed", async () => {
|
|
||||||
mockPrismaService.systemConfig.findUnique.mockResolvedValue({
|
|
||||||
id: "cfg-1",
|
|
||||||
key: "onboarding.completed",
|
|
||||||
value: "true",
|
|
||||||
encrypted: false,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(service.isCompleted()).resolves.toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createBreakglassUser hashes password and creates record", async () => {
|
|
||||||
const mockedHash = vi.mocked(hash);
|
|
||||||
mockedHash.mockResolvedValue("hashed-password");
|
|
||||||
|
|
||||||
mockPrismaService.breakglassUser.count.mockResolvedValue(0);
|
|
||||||
mockPrismaService.breakglassUser.create.mockResolvedValue({
|
|
||||||
id: "breakglass-1",
|
|
||||||
username: "admin",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.createBreakglassUser("admin", "supersecret123");
|
|
||||||
|
|
||||||
expect(mockedHash).toHaveBeenCalledWith("supersecret123", 12);
|
|
||||||
expect(mockPrismaService.breakglassUser.create).toHaveBeenCalledWith({
|
|
||||||
data: {
|
|
||||||
username: "admin",
|
|
||||||
passwordHash: "hashed-password",
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ id: "breakglass-1", username: "admin" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createBreakglassUser rejects if user already exists", async () => {
|
|
||||||
mockPrismaService.breakglassUser.count.mockResolvedValue(1);
|
|
||||||
|
|
||||||
await expect(service.createBreakglassUser("admin", "supersecret123")).rejects.toThrow(
|
|
||||||
"Breakglass user already exists"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("configureOidc encrypts secret and saves to SystemConfig", async () => {
|
|
||||||
mockCryptoService.encrypt.mockReturnValue("enc:oidc-secret");
|
|
||||||
mockPrismaService.systemConfig.upsert.mockResolvedValue({
|
|
||||||
id: "cfg",
|
|
||||||
key: "oidc.clientSecret",
|
|
||||||
value: "enc:oidc-secret",
|
|
||||||
encrypted: true,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.configureOidc("https://auth.example.com", "client-id", "client-secret");
|
|
||||||
|
|
||||||
expect(mockCryptoService.encrypt).toHaveBeenCalledWith("client-secret");
|
|
||||||
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledTimes(3);
|
|
||||||
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
|
|
||||||
where: { key: "oidc.issuerUrl" },
|
|
||||||
create: {
|
|
||||||
key: "oidc.issuerUrl",
|
|
||||||
value: "https://auth.example.com",
|
|
||||||
encrypted: false,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
value: "https://auth.example.com",
|
|
||||||
encrypted: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
|
|
||||||
where: { key: "oidc.clientId" },
|
|
||||||
create: {
|
|
||||||
key: "oidc.clientId",
|
|
||||||
value: "client-id",
|
|
||||||
encrypted: false,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
value: "client-id",
|
|
||||||
encrypted: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
|
|
||||||
where: { key: "oidc.clientSecret" },
|
|
||||||
create: {
|
|
||||||
key: "oidc.clientSecret",
|
|
||||||
value: "enc:oidc-secret",
|
|
||||||
encrypted: true,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
value: "enc:oidc-secret",
|
|
||||||
encrypted: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("addProvider encrypts apiKey and creates LlmProvider", async () => {
|
|
||||||
mockCryptoService.encrypt.mockReturnValue("enc:api-key");
|
|
||||||
mockPrismaService.llmProvider.create.mockResolvedValue({
|
|
||||||
id: "provider-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.addProvider("breakglass-1", {
|
|
||||||
name: "my-openai",
|
|
||||||
displayName: "OpenAI",
|
|
||||||
type: "openai",
|
|
||||||
baseUrl: "https://api.openai.com/v1",
|
|
||||||
apiKey: "sk-test",
|
|
||||||
models: [{ id: "gpt-4o-mini", name: "GPT-4o Mini" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockCryptoService.encrypt).toHaveBeenCalledWith("sk-test");
|
|
||||||
expect(mockPrismaService.llmProvider.create).toHaveBeenCalledWith({
|
|
||||||
data: {
|
|
||||||
userId: "breakglass-1",
|
|
||||||
name: "my-openai",
|
|
||||||
displayName: "OpenAI",
|
|
||||||
type: "openai",
|
|
||||||
baseUrl: "https://api.openai.com/v1",
|
|
||||||
apiKey: "enc:api-key",
|
|
||||||
models: [{ id: "gpt-4o-mini", name: "GPT-4o Mini" }],
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ id: "provider-1" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("complete sets SystemConfig flag", async () => {
|
|
||||||
mockPrismaService.systemConfig.upsert.mockResolvedValue({
|
|
||||||
id: "cfg-1",
|
|
||||||
key: "onboarding.completed",
|
|
||||||
value: "true",
|
|
||||||
encrypted: false,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.complete();
|
|
||||||
|
|
||||||
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
|
|
||||||
where: { key: "onboarding.completed" },
|
|
||||||
create: {
|
|
||||||
key: "onboarding.completed",
|
|
||||||
value: "true",
|
|
||||||
encrypted: false,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
value: "true",
|
|
||||||
encrypted: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
|
|
||||||
import type { InputJsonValue } from "@prisma/client/runtime/library";
|
|
||||||
import { hash } from "bcryptjs";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import { CryptoService } from "../crypto/crypto.service";
|
|
||||||
|
|
||||||
const BCRYPT_ROUNDS = 12;
|
|
||||||
const TEST_PROVIDER_TIMEOUT_MS = 8000;
|
|
||||||
|
|
||||||
const ONBOARDING_COMPLETED_KEY = "onboarding.completed";
|
|
||||||
const OIDC_ISSUER_URL_KEY = "oidc.issuerUrl";
|
|
||||||
const OIDC_CLIENT_ID_KEY = "oidc.clientId";
|
|
||||||
const OIDC_CLIENT_SECRET_KEY = "oidc.clientSecret";
|
|
||||||
|
|
||||||
interface ProviderModelInput {
|
|
||||||
id: string;
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddProviderInput {
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
type: string;
|
|
||||||
baseUrl?: string;
|
|
||||||
apiKey?: string;
|
|
||||||
models?: ProviderModelInput[];
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OnboardingService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly crypto: CryptoService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// Check if onboarding is completed
|
|
||||||
async isCompleted(): Promise<boolean> {
|
|
||||||
const completedFlag = await this.prisma.systemConfig.findUnique({
|
|
||||||
where: { key: ONBOARDING_COMPLETED_KEY },
|
|
||||||
});
|
|
||||||
|
|
||||||
return completedFlag?.value === "true";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Create breakglass admin user
|
|
||||||
async createBreakglassUser(
|
|
||||||
username: string,
|
|
||||||
password: string
|
|
||||||
): Promise<{ id: string; username: string }> {
|
|
||||||
const breakglassCount = await this.prisma.breakglassUser.count();
|
|
||||||
if (breakglassCount > 0) {
|
|
||||||
throw new ConflictException("Breakglass user already exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await hash(password, BCRYPT_ROUNDS);
|
|
||||||
|
|
||||||
return this.prisma.breakglassUser.create({
|
|
||||||
data: {
|
|
||||||
username,
|
|
||||||
passwordHash,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Configure OIDC provider (optional)
|
|
||||||
async configureOidc(issuerUrl: string, clientId: string, clientSecret: string): Promise<void> {
|
|
||||||
const encryptedSecret = this.crypto.encrypt(clientSecret);
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
this.upsertSystemConfig(OIDC_ISSUER_URL_KEY, issuerUrl, false),
|
|
||||||
this.upsertSystemConfig(OIDC_CLIENT_ID_KEY, clientId, false),
|
|
||||||
this.upsertSystemConfig(OIDC_CLIENT_SECRET_KEY, encryptedSecret, true),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Add first LLM provider
|
|
||||||
async addProvider(userId: string, data: AddProviderInput): Promise<{ id: string }> {
|
|
||||||
const encryptedApiKey = data.apiKey ? this.crypto.encrypt(data.apiKey) : undefined;
|
|
||||||
|
|
||||||
return this.prisma.llmProvider.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
name: data.name,
|
|
||||||
displayName: data.displayName,
|
|
||||||
type: data.type,
|
|
||||||
baseUrl: data.baseUrl ?? null,
|
|
||||||
apiKey: encryptedApiKey ?? null,
|
|
||||||
models: (data.models ?? []) as unknown as InputJsonValue,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3b: Test LLM provider connection
|
|
||||||
async testProvider(
|
|
||||||
type: string,
|
|
||||||
baseUrl?: string,
|
|
||||||
apiKey?: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const normalizedType = type.trim().toLowerCase();
|
|
||||||
if (!normalizedType) {
|
|
||||||
return { success: false, error: "Provider type is required" };
|
|
||||||
}
|
|
||||||
|
|
||||||
let probeUrl: string;
|
|
||||||
try {
|
|
||||||
probeUrl = this.buildProbeUrl(normalizedType, baseUrl);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return { success: false, error: message };
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
Accept: "application/json",
|
|
||||||
};
|
|
||||||
if (apiKey) {
|
|
||||||
headers.Authorization = `Bearer ${apiKey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(probeUrl, {
|
|
||||||
method: "GET",
|
|
||||||
headers,
|
|
||||||
signal: AbortSignal.timeout(TEST_PROVIDER_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Provider returned ${String(response.status)} ${response.statusText}`.trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return { success: false, error: message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Mark onboarding complete
|
|
||||||
async complete(): Promise<void> {
|
|
||||||
await this.upsertSystemConfig(ONBOARDING_COMPLETED_KEY, "true", false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBreakglassUserId(): Promise<string> {
|
|
||||||
const user = await this.prisma.breakglassUser.findFirst({
|
|
||||||
where: { isActive: true },
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new BadRequestException("Create a breakglass user before adding a provider");
|
|
||||||
}
|
|
||||||
|
|
||||||
return user.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async upsertSystemConfig(key: string, value: string, encrypted: boolean): Promise<void> {
|
|
||||||
await this.prisma.systemConfig.upsert({
|
|
||||||
where: { key },
|
|
||||||
create: { key, value, encrypted },
|
|
||||||
update: { value, encrypted },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildProbeUrl(type: string, baseUrl?: string): string {
|
|
||||||
const resolvedBaseUrl = baseUrl ?? this.getDefaultProviderBaseUrl(type);
|
|
||||||
const normalizedBaseUrl = resolvedBaseUrl.endsWith("/")
|
|
||||||
? resolvedBaseUrl
|
|
||||||
: `${resolvedBaseUrl}/`;
|
|
||||||
const endpointPath = type === "ollama" ? "api/tags" : "models";
|
|
||||||
|
|
||||||
return new URL(endpointPath, normalizedBaseUrl).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDefaultProviderBaseUrl(type: string): string {
|
|
||||||
if (type === "ollama") {
|
|
||||||
return "http://localhost:11434";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "https://api.openai.com/v1";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user