Compare commits
1 Commits
fix/logs-p
...
feat/ms22-
| Author | SHA1 | Date | |
|---|---|---|---|
| cbb0dc8aff |
@@ -53,6 +53,7 @@ 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";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -127,6 +128,7 @@ import { ContainerLifecycleModule } from "./container-lifecycle/container-lifecy
|
|||||||
ConversationArchiveModule,
|
ConversationArchiveModule,
|
||||||
AgentConfigModule,
|
AgentConfigModule,
|
||||||
ContainerLifecycleModule,
|
ContainerLifecycleModule,
|
||||||
|
FleetSettingsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
115
apps/api/src/fleet-settings/fleet-settings.controller.ts
Normal file
115
apps/api/src/fleet-settings/fleet-settings.controller.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
apps/api/src/fleet-settings/fleet-settings.dto.ts
Normal file
122
apps/api/src/fleet-settings/fleet-settings.dto.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
13
apps/api/src/fleet-settings/fleet-settings.module.ts
Normal file
13
apps/api/src/fleet-settings/fleet-settings.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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 {}
|
||||||
200
apps/api/src/fleet-settings/fleet-settings.service.spec.ts
Normal file
200
apps/api/src/fleet-settings/fleet-settings.service.spec.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
296
apps/api/src/fleet-settings/fleet-settings.service.ts
Normal file
296
apps/api/src/fleet-settings/fleet-settings.service.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
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 : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user