Compare commits
1 Commits
feat/ms22-
...
feat/ms22-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d66e00710 |
@@ -53,7 +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";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -128,7 +127,6 @@ import { FleetSettingsModule } from "./fleet-settings/fleet-settings.module";
|
|||||||
ConversationArchiveModule,
|
ConversationArchiveModule,
|
||||||
AgentConfigModule,
|
AgentConfigModule,
|
||||||
ContainerLifecycleModule,
|
ContainerLifecycleModule,
|
||||||
FleetSettingsModule,
|
|
||||||
],
|
],
|
||||||
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 : [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user