feat(api): fleet settings API (MS22-P1g) (#611)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #611.
This commit is contained in:
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